diff --git a/docs/wallet-hardening.md b/docs/wallet-hardening.md index dceb8d5..84f5631 100644 --- a/docs/wallet-hardening.md +++ b/docs/wallet-hardening.md @@ -21,7 +21,7 @@ Status legend: ☐ not started · ◐ in progress · ☑ landed & verified | **P1-A** | W3-1, W3-2, W3-4 ✓ · W3-3 ⚑ | Migrate-to-seed correctness (fund-adjacent) | ◐ 3/4 | | **P1-B** | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ | | **P2** | W5-1, W5-2, W6-1, W6-3 ✓ · W6-2 ☐ | Stale state & lite save-failure surfacing | ◐ 4/5 | -| **F** | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log ✓, alert-history/error-banner/staleness ☐ | Diagnostics foundation + QoL bundle | ◐ | +| **F** | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log + node-error-banner ✓, alert-history/staleness ☐ | Diagnostics foundation + QoL bundle | ◐ | --- @@ -112,6 +112,7 @@ Land W7-2 first — it unblocks the rest. ## Progress log +- **Foundation QoL / Persistent node-status banner** — ☑ landed. A persistent horizontal strip now sits at the top of the content column whenever the wallet can't reach its node — distinct from the transient toasts, so an offline wallet is never silently mistaken for a working one. The show/severity/action decision is a pure function (`ui/node_status_banner.h` → `evaluateNodeStatusBanner`, unit-tested) fed a state snapshot by `App::renderNodeStatusBanner()`. Three cases: **full-node offline** (amber, "Reconnect" → `tryConnect`), **embedded daemon crashed & auto-restart gave up** (red, "Restart node" → `restartDaemon`), **lite wallet failed to open** (red, message-only). Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown, and while an expected startup phase (warmup/init/connect-in-progress) already owns the screen. Height in `res/themes/ui.toml` (`banners.node-status`); colours from the material semantic palette; detail text ellipsis-clipped so it can't shove the action button off-screen. Build-clean; `ctest` 1/1 (added `testNodeStatusBanner`). **Remaining QoL:** persistent alert history, and the W6-2 refresh-staleness badge. - **Foundation QoL / "Copy diagnostics" + "Open log folder"** — ☑ landed: Settings (logging section) now has two actions. **Open log folder** opens the config dir (`Platform::openFolder`) so users can actually find `dragonx-debug.log`/`dragonx-crash.log`. **Copy diagnostics** copies a plaintext support snapshot to the clipboard via the new `App::buildDiagnosticsReport()` — version, build variant, platform, connection status, active wallet path + existence + size, encryption/lock state, sync heights, daemon status/running/crash-count/lastError (full-node), and the log paths. No secrets. Build-clean; `ctest` 1/1. **Remaining QoL:** persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge. - **Foundation / W7-2 · W7-3 · W7-4 (diagnostics infrastructure)** — ☑ landed (answers the original "easier to diagnose" ask — the logging/crash foundation now actually works): - **W7-2 (Med, keystone):** the app-level `Logger` file sink was never initialized, so `LOG`/`LOGF`/`VERBOSE_LOGF` went nowhere and `dragonx-debug.log` didn't exist on Linux/macOS at all. `main()` now calls `Logger::init(/dragonx-debug.log)` on all platforms. Also fixed a **latent deadlock** this exposed: `init()` wrote its banner via `write()`, which re-locks the non-recursive `mutex_` it already holds — now written directly. On Windows the raw stdout/stderr `freopen` was moved to a separate `dragonx-stdout.log` so the two writers don't contend. New `testLoggerFileSink` (also a deadlock guard — it would hang if that regressed). diff --git a/res/themes/ui.toml b/res/themes/ui.toml index 192802a..ef6cbee 100644 --- a/res/themes/ui.toml +++ b/res/themes/ui.toml @@ -700,6 +700,12 @@ status-pill-bg-alpha = { size = 30 } status-pill-y-offset = { size = 1 } confirmed-threshold = { size = 10 } +# Persistent node/RPC error strip at the top of the content column (see App::renderNodeStatusBanner). +# Slightly taller than the per-tab sync banner so it comfortably holds the Reconnect/Restart action. +[banners.node-status] +min-height = { size = 26.0 } +height = { size = 30.0 } + [tabs.transactions] search-max-width = 300.0 search-width-ratio = 0.3 diff --git a/src/app.cpp b/src/app.cpp index ad7201d..00e6e95 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -68,6 +68,7 @@ #include "ui/material/draw_helpers.h" #include "ui/widgets/copy_field.h" #include "ui/notifications.h" +#include "ui/node_status_banner.h" #include "util/i18n.h" #include "util/connect_stall.h" #include "util/platform.h" @@ -1794,6 +1795,11 @@ void App::render() ImGui::BeginChild("##ContentArea", ImVec2(0, contentH), false, contentFlags); + // Persistent node/RPC error banner — drawn first (before the edge-fade vertex capture below, + // so it stays fully opaque) and above every page / overlay in the content column. It renders + // nothing and consumes no space while the node is reachable. + renderNodeStatusBanner(); + // Capture vertex start for edge fade mask ImDrawList* caDL = ImGui::GetWindowDrawList(); int caVtxStart = caDL->VtxBuffer.Size; @@ -2142,6 +2148,141 @@ void App::render() ui::material::LatchBlurOverlayActive(); } +void App::renderNodeStatusBanner() +{ + namespace m = ui::material; + + // Suppress during flows that legitimately have no connection, so the banner never contradicts + // an overlay the app is already showing: the first-run wizard (no daemon started yet), a + // wallet switch, an in-flight daemon restart, the screenshot sweep (forces demo state), and + // shutdown. tryConnect() sets connection_in_progress_ before the first render on normal + // startup, so the ordinary boot path is covered by the evaluator's own in-progress guard. + if (capture_mode_ || isShuttingDown()) return; + if (getWizardPhase() != WizardPhase::None) return; + if (wallet_switch_phase_.load() != 0) return; + if (daemon_restarting_.load()) return; + + ui::NodeBannerInputs in; + in.lite = isLiteBuild(); + in.connected = state_.connected; + in.warming_up = state_.warming_up; + in.daemon_initializing = state_.daemon_initializing; + in.connection_in_progress = connection_in_progress_; + in.using_embedded_daemon = isUsingEmbeddedDaemon(); + in.has_daemon_controller = (daemon_controller_ != nullptr); + in.daemon_running = isEmbeddedDaemonRunning(); + in.daemon_crash_count = daemon_controller_ ? daemon_controller_->crashCount() : 0; + in.connection_status = connection_status_; + in.daemon_last_error = daemon_controller_ ? daemon_controller_->lastError() : std::string(); + in.lite_open_error = lite_open_error_; + + const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in); + if (!banner.show) return; + + const auto& S = ui::schema::UI(); + const float minH = S.drawElement("banners.node-status", "min-height").size; + const float baseH = S.drawElement("banners.node-status", "height").size; + const float bannerH = std::max(minH, baseH * ui::Layout::vScale()); + + const bool isError = (banner.severity == ui::NodeBannerSeverity::Error); + const ImU32 sevCol = isError ? m::Error() : m::Warning(); + const ImU32 bgCol = m::WithAlphaF(sevCol, isError ? 0.20f : 0.15f); + + // Translated headline for the reason; `detail` is the live status text (may be empty). + const char* title; + const char* icon; + switch (banner.reason) { + case ui::NodeBannerReason::DaemonCrashed: + title = TR("node_banner_crashed_title"); icon = ICON_MD_ERROR; break; + case ui::NodeBannerReason::LiteOpenFailed: + title = TR("node_banner_lite_open_failed"); icon = ICON_MD_ERROR; break; + case ui::NodeBannerReason::FullNodeOffline: + default: + title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break; + } + + const char* actionLabel = nullptr; + if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect"); + else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart"); + + const float padX = ui::Layout::spacingLg(); + + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(bgCol)); + ImGui::BeginChild("##NodeStatusBanner", + ImVec2(ImGui::GetContentRegionAvail().x, bannerH), false, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + const float winW = ImGui::GetWindowSize().x; + + ImFont* icoFont = m::Type().iconSmall(); + ImFont* txtFont = m::Type().body2(); + + // Icon — centered on its own metrics. + ImGui::SetCursorPos(ImVec2(padX, (bannerH - icoFont->LegacySize) * 0.5f)); + ImGui::PushFont(icoFont); + ImGui::PushStyleColor(ImGuiCol_Text, sevCol); + ImGui::TextUnformatted(icon); + ImGui::PopStyleColor(); + ImGui::PopFont(); + + const float txtCy = (bannerH - txtFont->LegacySize) * 0.5f; + + // Right-aligned action button geometry (measured first so the detail text can be clipped to + // never run underneath it). + float btnW = 0.0f, btnH = 0.0f, actionReserve = 0.0f; + if (actionLabel) { + btnH = std::max(0.0f, bannerH - ui::Layout::spacingSm() * 2.0f); + btnW = ImGui::CalcTextSize(actionLabel).x + ui::Layout::spacingLg() * 1.6f; + actionReserve = btnW + padX + ui::Layout::spacingMd(); + } + + // Title. + ImGui::SameLine(0.0f, ui::Layout::spacingSm()); + ImGui::SetCursorPosY(txtCy); + ImGui::PushFont(txtFont); + ImGui::PushStyleColor(ImGuiCol_Text, sevCol); + ImGui::TextUnformatted(title); + ImGui::PopStyleColor(); + + // Detail (dim) on the same row, clipped with an ellipsis so it can't push the button off-screen. + if (!banner.detail.empty()) { + ImGui::SameLine(0.0f, ui::Layout::spacingSm()); + ImGui::SetCursorPosY(txtCy); + const float budget = winW - ImGui::GetCursorPosX() - actionReserve; + if (budget > ImGui::CalcTextSize("W").x) { + const std::string prefix = "\xC2\xB7 "; // "· " + std::string detail = banner.detail; + std::string shown = prefix + detail; + if (ImGui::CalcTextSize(shown.c_str()).x > budget) { + const std::string ell = "\xE2\x80\xA6"; // "…" + while (!detail.empty() && + ImGui::CalcTextSize((prefix + detail + ell).c_str()).x > budget) { + detail.pop_back(); + while (!detail.empty() && + (static_cast(detail.back()) & 0xC0) == 0x80) + detail.pop_back(); // drop the whole trailing UTF-8 code point + } + shown = prefix + detail + ell; + } + ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceMedium()); + ImGui::TextUnformatted(shown.c_str()); + ImGui::PopStyleColor(); + } + } + ImGui::PopFont(); + + // Action button. + if (actionLabel) { + ImGui::SetCursorPos(ImVec2(winW - btnW - padX, (bannerH - btnH) * 0.5f)); + if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) { + if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon(); + else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect(); + } + } + + ImGui::EndChild(); + ImGui::PopStyleColor(); +} + void App::renderStatusBar() { // Status bar layout from unified UI schema diff --git a/src/app.h b/src/app.h index 98c2c00..f45663e 100644 --- a/src/app.h +++ b/src/app.h @@ -1295,6 +1295,10 @@ private: // Private methods - rendering void renderStatusBar(); + // Persistent node/RPC error strip at the top of the content column when the wallet can't + // reach its node (or the embedded daemon gave up crashing). Decision logic is the pure + // evaluateNodeStatusBanner() in ui/node_status_banner.h; this draws it and wires the action. + void renderNodeStatusBanner(); void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet void renderLiteUnlockPrompt(); // lite-only send-time unlock modal void renderImportKeyDialog(); diff --git a/src/ui/node_status_banner.h b/src/ui/node_status_banner.h new file mode 100644 index 0000000..6233e5e --- /dev/null +++ b/src/ui/node_status_banner.h @@ -0,0 +1,111 @@ +#pragma once + +#include + +// Persistent node-connectivity banner shown at the top of the content column when the wallet +// cannot reach its node. Distinct from the transient toast notifications: it stays visible for +// as long as the fault persists, so an offline wallet is never silently mistaken for a working +// one. The decision (whether to show, how severe, which action) is a pure function of a state +// snapshot so it can be unit-tested; App::renderNodeStatusBanner() feeds it the live state and +// draws the strip. See src/app.cpp. +namespace dragonx::ui { + +// Visual weight. Warning (amber) = recoverable / a reconnect is offered; Error (red) = a hard +// fault the user must act on (the daemon gave up crashing, or a lite wallet failed to open). +enum class NodeBannerSeverity { + Warning, + Error, +}; + +// What the banner's action button does. App maps this to the concrete call. +enum class NodeBannerAction { + None, // no button — nothing the user can usefully do from here + Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect) + RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon) +}; + +// Why the banner is up. App maps this to a translated headline; `detail` carries the live, +// already-human-readable status text (connection_status_ / daemon lastError / lite open error). +enum class NodeBannerReason { + None, + FullNodeOffline, // a reachable node was lost, or never came up; reconnect offered + DaemonCrashed, // the embedded daemon crashed repeatedly and auto-restart stopped + LiteOpenFailed, // lite build: the wallet failed to open +}; + +struct NodeBannerState { + bool show = false; + NodeBannerSeverity severity = NodeBannerSeverity::Warning; + NodeBannerReason reason = NodeBannerReason::None; + NodeBannerAction action = NodeBannerAction::None; + std::string detail; // passthrough status/error text (may be empty) +}; + +// Snapshot of the connection state the banner reads. Plain values so the decision is testable +// without an App instance. +struct NodeBannerInputs { + bool lite = false; // lite build (no embedded daemon / RPC) + bool connected = false; // state_.connected — the master "online" flag + bool warming_up = false; // daemon reachable, RPC warmup (code -28) + bool daemon_initializing = false; // daemon launching / block index loading + bool connection_in_progress = false; // a connect attempt is actively running + + // Full-node embedded-daemon crash signal. + bool using_embedded_daemon = false; + bool has_daemon_controller = false; + bool daemon_running = false; + int daemon_crash_count = 0; + + std::string connection_status; // human-readable status line (already translated) + std::string daemon_last_error; // DaemonController::lastError() (may be empty) + std::string lite_open_error; // lite: last wallet-open failure reason +}; + +// Auto-restart give-up threshold — mirrors the crash cap in app_network.cpp's connect loop. +inline constexpr int kNodeBannerCrashGiveUpCount = 3; + +inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) { + NodeBannerState s; + + if (in.lite) { + // Lite has no daemon/RPC; "online" == wallet open. Only a genuine open failure is a + // fault worth a persistent banner (a not-yet-created wallet is handled by the normal + // "No wallet open" prompt, and leaves lite_open_error empty). + if (!in.connected && !in.lite_open_error.empty()) { + s.show = true; + s.severity = NodeBannerSeverity::Error; + s.reason = NodeBannerReason::LiteOpenFailed; + s.action = NodeBannerAction::None; + s.detail = in.lite_open_error; + } + return s; + } + + // Full node. Connected, or in an expected startup phase → the loading/warmup overlay owns + // the screen, so no banner. An active connect attempt likewise shows progress, not an + // error — don't flicker a banner over it. + if (in.connected) return s; + if (in.warming_up || in.daemon_initializing) return s; + if (in.connection_in_progress) return s; + + // Genuinely offline. Distinguish "the embedded daemon crashed and we stopped retrying" (a + // hard fault needing a manual restart) from an ordinary lost/failed connection (retryable). + if (in.using_embedded_daemon && in.has_daemon_controller && !in.daemon_running && + in.daemon_crash_count >= kNodeBannerCrashGiveUpCount) { + s.show = true; + s.severity = NodeBannerSeverity::Error; + s.reason = NodeBannerReason::DaemonCrashed; + s.action = NodeBannerAction::RestartNode; + s.detail = !in.daemon_last_error.empty() ? in.daemon_last_error : in.connection_status; + return s; + } + + s.show = true; + s.severity = NodeBannerSeverity::Warning; + s.reason = NodeBannerReason::FullNodeOffline; + s.action = NodeBannerAction::Reconnect; + s.detail = in.connection_status; + return s; +} + +} // namespace dragonx::ui diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 656aadc..08918da 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1309,6 +1309,12 @@ void I18n::loadBuiltinEnglish() strings_["sb_connecting_err"] = "Connecting to daemon — %s"; strings_["sb_daemon_crashed"] = "Daemon crashed %d times"; strings_["sb_daemon_start_failed"] = "Couldn't start dragonxd"; + // Persistent node-status banner (App::renderNodeStatusBanner). + strings_["node_banner_offline_title"] = "Not connected to the DragonX node"; + strings_["node_banner_crashed_title"] = "The node stopped unexpectedly"; + strings_["node_banner_lite_open_failed"] = "Couldn't open your wallet"; + strings_["node_banner_reconnect"] = "Reconnect"; + strings_["node_banner_restart"] = "Restart node"; strings_["daemon_port_busy_warn"] = "Port " DRAGONX_DEFAULT_RPC_PORT " is in use but isn't responding as a DragonX node. " "Close the program using it (or free the port), then restart — the wallet can't start " diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 2fe7757..dac09ff 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -32,6 +32,7 @@ #include "ui/windows/mining_benchmark.h" #include "ui/windows/mining_pool_panel.h" #include "ui/windows/mining_tab_helpers.h" +#include "ui/node_status_banner.h" #include "util/address_validation.h" #include "util/amount_format.h" #include "util/payment_uri.h" @@ -2547,6 +2548,81 @@ void testConsoleSecretRedaction() EXPECT_EQ(RedactConsoleCommand("getwalletinfo"), std::string("getwalletinfo")); } +void testNodeStatusBanner() +{ + using namespace dragonx::ui; + + // Connected full node → no banner. + { + NodeBannerInputs in; in.connected = true; + EXPECT_TRUE(!evaluateNodeStatusBanner(in).show); + } + // Expected startup phases own the screen (loading/warmup overlay) → no banner. + { + NodeBannerInputs in; in.warming_up = true; + EXPECT_TRUE(!evaluateNodeStatusBanner(in).show); + NodeBannerInputs in2; in2.daemon_initializing = true; + EXPECT_TRUE(!evaluateNodeStatusBanner(in2).show); + NodeBannerInputs in3; in3.connection_in_progress = true; + EXPECT_TRUE(!evaluateNodeStatusBanner(in3).show); + } + // Genuinely offline full node → amber, reconnect offered, detail passed through. + { + NodeBannerInputs in; + in.connection_status = "Lost connection to daemon"; + NodeBannerState s = evaluateNodeStatusBanner(in); + EXPECT_TRUE(s.show); + EXPECT_TRUE(s.severity == NodeBannerSeverity::Warning); + EXPECT_TRUE(s.reason == NodeBannerReason::FullNodeOffline); + EXPECT_TRUE(s.action == NodeBannerAction::Reconnect); + EXPECT_EQ(s.detail, std::string("Lost connection to daemon")); + } + // Embedded daemon crashed and auto-restart gave up → red, restart offered, lastError preferred. + { + NodeBannerInputs in; + in.using_embedded_daemon = true; + in.has_daemon_controller = true; + in.daemon_running = false; + in.daemon_crash_count = kNodeBannerCrashGiveUpCount; + in.daemon_last_error = "exit code 134"; + in.connection_status = "Daemon crashed 3 times"; + NodeBannerState s = evaluateNodeStatusBanner(in); + EXPECT_TRUE(s.show); + EXPECT_TRUE(s.severity == NodeBannerSeverity::Error); + EXPECT_TRUE(s.reason == NodeBannerReason::DaemonCrashed); + EXPECT_TRUE(s.action == NodeBannerAction::RestartNode); + EXPECT_EQ(s.detail, std::string("exit code 134")); + } + // Below the give-up threshold it's still just an offline/reconnect banner, not the crash one. + { + NodeBannerInputs in; + in.using_embedded_daemon = true; + in.has_daemon_controller = true; + in.daemon_running = false; + in.daemon_crash_count = kNodeBannerCrashGiveUpCount - 1; + NodeBannerState s = evaluateNodeStatusBanner(in); + EXPECT_TRUE(s.show); + EXPECT_TRUE(s.reason == NodeBannerReason::FullNodeOffline); + EXPECT_TRUE(s.action == NodeBannerAction::Reconnect); + } + // Lite: an open failure shows a red, action-less banner; no failure → nothing. + { + NodeBannerInputs in; in.lite = true; in.connected = false; + in.lite_open_error = "wallet.dat is corrupt"; + NodeBannerState s = evaluateNodeStatusBanner(in); + EXPECT_TRUE(s.show); + EXPECT_TRUE(s.severity == NodeBannerSeverity::Error); + EXPECT_TRUE(s.reason == NodeBannerReason::LiteOpenFailed); + EXPECT_TRUE(s.action == NodeBannerAction::None); + EXPECT_EQ(s.detail, std::string("wallet.dat is corrupt")); + + NodeBannerInputs clean; clean.lite = true; clean.connected = false; // no error yet + EXPECT_TRUE(!evaluateNodeStatusBanner(clean).show); + NodeBannerInputs open; open.lite = true; open.connected = true; + EXPECT_TRUE(!evaluateNodeStatusBanner(open).show); + } +} + void testLoggerFileSink() { using dragonx::util::Logger; @@ -6934,6 +7010,7 @@ int main() testIsLocalHost(); testAllowsPlaintextRemote(); testConsoleSecretRedaction(); + testNodeStatusBanner(); testLoggerFileSink(); testDaemonLifecycleExecution(); testDaemonLifecycleAdapters();