From 5edbe8a276d9f5ef38df74783a098773aa79c172 Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 10 Aug 2026 22:11:26 -0500 Subject: [PATCH] feat(recovery): redesign the wallet auto-recovery flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the "Daemon Error + raw log dump" moment into one calm, honest recovery dialog plus a recovery-aware rescan screen. Presentation + orchestration only — the file-safety logic in rebuildWalletDatabase()/restoreOriginalWallet() (source selection, verify-before-swap, copy/rename-never-delete, .bak) is unchanged. - One authoritative dialog with a phase machine Offer -> Working -> Done/Failed. The duplicate in-overlay recovery card, the untranslated red "Daemon Error" heading, and the raw daemon-log dump are gone for the recovery case (they stay for genuine, unrelated crashes). - Offer is a choice-cards layout: "Repair automatically" (recommended, accent- tinted) vs "Restore original", side by side; the rare actions ("Show me the files", "Decide later") and a plain-language "What happens to my files?" sit in a quiet footer. When the rebuild helper is missing, it collapses to a single Restore card — never a dead end. - Post-repair rescan shows a calm "Finishing your wallet repair" screen with elapsed time + the growing wallet size, instead of "RPC timeout / taking longer than expected / restart daemon"; the daemon-crash toast is suppressed and the detection toast is downgraded from red to info. - Fixes a confirmed dead-end: if a repair succeeds but the restarted daemon then crashes for a *different* reason (block index, disk, OOM), the recovery flags now clear (in tryConnect + onConnected) so it surfaces as a normal daemon failure instead of freezing forever on a reassuring "don't restart" screen. - Clickable "Wallet repair available" status-bar chip for re-entry. The same app.cpp changes HiDPI-harden the surfaces the recovery flow lives on: the status-bar and loading-overlay hand-drawn geometry are multiplied by dpiScale (they rendered native-size and clipped at HiDPI / font_scale>1), the loading- overlay status text wraps instead of running off both edges, and the node-status banner floors its height to its DPI-baked font so the title can't clip off the top. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.cpp | 608 ++++++++++++++++++++++++++++++++++++-------- src/app.h | 14 + src/app_network.cpp | 54 +++- src/util/i18n.cpp | 58 ++++- 4 files changed, 604 insertions(+), 130 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index 195ea6c..05cc0f7 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1644,8 +1644,10 @@ void App::render() float v = ui::schema::UI().drawElement("components.sidebar", key).size; return (v >= 0 ? v : fb) * dp; }; - float statusBarH = ui::schema::UI().window("components.status-bar").height; - if (statusBarH <= 0.0f) statusBarH = 24.0f; // safety fallback + // Scale by dp to match the rendered status-bar child height (renderStatusBar), so the reserved + // content strip stays in step with the bar at HiDPI instead of under-reserving. + float statusBarH = ui::schema::UI().window("components.status-bar").height * dp; + if (statusBarH <= 0.0f) statusBarH = 24.0f * dp; // safety fallback // Content area padding from ui.toml schema const auto& caWin = ui::schema::UI().window("components.content-area"); const float caMarginTop = ui::schema::UI().drawElement("components.content-area", "margin-top").size; @@ -2189,9 +2191,18 @@ void App::renderNodeStatusBanner() 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; + + // Fonts up-front so the banner height can never be shorter than the glyph row it vertically + // centers — otherwise the title/icon draw above the child's top clip rect and slice off (seen at + // HiDPI / large font scale, where the DPI-baked glyphs outgrow the schema height). Metrics scaled. + ImFont* icoFont = m::Type().iconSmall(); + ImFont* txtFont = m::Type().body2(); + const float glyphH = std::max(icoFont ? icoFont->LegacySize : 0.0f, + txtFont ? txtFont->LegacySize : 0.0f); // Both operands must be in scaled px: vScale() already folds in dpiScale(), so the raw min-height // floor needs the same dpiScale() or it under-clamps the banner at HiDPI. - const float bannerH = std::max(minH * ui::Layout::dpiScale(), baseH * ui::Layout::vScale()); + float bannerH = std::max(minH * ui::Layout::dpiScale(), baseH * ui::Layout::vScale()); + bannerH = std::max(bannerH, glyphH + ui::Layout::spacingSm() * 2.0f); // never shorter than text const bool isError = (banner.severity == ui::NodeBannerSeverity::Error); const ImU32 sevCol = isError ? m::Error() : m::Warning(); @@ -2222,18 +2233,15 @@ void App::renderNodeStatusBanner() 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)); + // Icon — centered on its own metrics (clamped so it never draws above the child's top). + ImGui::SetCursorPos(ImVec2(padX, std::max(0.0f, (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; + const float txtCy = std::max(0.0f, (bannerH - txtFont->LegacySize) * 0.5f); // Right-aligned action button geometry (measured first so the detail text can be clipped to // never run underneath it). @@ -2281,7 +2289,7 @@ void App::renderNodeStatusBanner() // Action button. if (actionLabel) { - ImGui::SetCursorPos(ImVec2(winW - btnW - padX, (bannerH - btnH) * 0.5f)); + ImGui::SetCursorPos(ImVec2(winW - btnW - padX, std::max(0.0f, (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(); @@ -2388,12 +2396,15 @@ void App::renderStatusBar() // Status bar layout from unified UI schema const auto& S = ui::schema::UI(); const auto& sbWin = S.window("components.status-bar"); - const float sbHeight = sbWin.height; - const float sbPadX = sbWin.padding[0]; - const float sbPadY = sbWin.padding[1]; - const float sbIconTextGap = S.drawElement("components.status-bar", "icon-text-gap").size; - const float sbSectionGap = S.drawElement("components.status-bar", "section-gap").size; - const float sbSeparatorGap = S.drawElement("components.status-bar", "separator-gap").size; + // Schema values are logical px; the fonts/icons drawn inside are DPI-baked, so the box and its + // gaps must be scaled by the same factor or they clip the text at HiDPI / font_scale > 1. + const float dp = ui::Layout::dpiScale(); + const float sbHeight = sbWin.height * dp; + const float sbPadX = sbWin.padding[0] * dp; + const float sbPadY = sbWin.padding[1] * dp; + const float sbIconTextGap = S.drawElement("components.status-bar", "icon-text-gap").size * dp; + const float sbSectionGap = S.drawElement("components.status-bar", "section-gap").size * dp; + const float sbSeparatorGap = S.drawElement("components.status-bar", "separator-gap").size * dp; ImGuiWindowFlags childFlags = ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse; @@ -2625,6 +2636,8 @@ void App::renderStatusBar() // Compute positions dynamically from actual text widths so they // never overlap and always stay within the window at any font scale. { + // Where the left-side chain ended, so the right cluster never SameLine()s backward onto it. + const float leftEndX = ImGui::GetCursorPosX(); char versionBuf[32]; snprintf(versionBuf, sizeof(versionBuf), "v%s", DRAGONX_VERSION); float versionW = ImGui::CalcTextSize(versionBuf).x; @@ -2636,10 +2649,36 @@ void App::renderStatusBar() float gap = sbSectionGap; float occupiedX = versionX; // leftmost X used by the version + connection status so far if (!connection_status_.empty() && connection_status_ != "Connected") { - float statusW = ImGui::CalcTextSize(connection_status_.c_str()).x; + // During a post-repair rescan the raw status is a scary "RPC request failed: Timeout" — show a + // calm line instead (the rescan legitimately can't answer RPC yet). + const std::string statusBase = post_recovery_rescan_ ? std::string(TR("sb_finishing_repair")) + : connection_status_; + // Middle-ellipsize to the space between the left chain and the version so a long status + // (e.g. "Wallet needs recovery — see the prompt") can't push off-screen or overprint the + // left chain; then clamp its start so it never crosses left of where the chain ended. + float availW = versionX - leftEndX - gap * 2.0f; + if (availW < 24.0f * dp) availW = 24.0f * dp; + ImFont* stFont = ImGui::GetFont(); + std::string statusShown = ui::material::TruncateToWidth( + statusBase, stFont, stFont->LegacySize, availW); + float statusW = ImGui::CalcTextSize(statusShown.c_str()).x; float statusX = versionX - statusW - gap; + if (statusX < leftEndX + gap) statusX = leftEndX + gap; ImGui::SameLine(statusX); - ImGui::TextDisabled("%s", connection_status_.c_str()); + if (wallet_auto_recovered_ && !post_recovery_rescan_) { + // Actionable re-entry: a full-opacity accent chip that reopens the recovery dialog. + // Dismiss is non-destructive, so this is the guaranteed way back to the prompt. + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning()); + ImGui::TextUnformatted(statusShown.c_str()); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (ImGui::IsItemClicked() && recovery_phase_ != RecoveryPhase::Working) { + show_wallet_recovered_dialog_ = true; // reopen the (non-destructively dismissed) prompt + recovery_phase_ = RecoveryPhase::Offer; + } + } else { + ImGui::TextDisabled("%s", statusShown.c_str()); + } occupiedX = statusX; } else if (!daemon_status_.empty() && daemon_status_.find("Error") != std::string::npos) { const char* errText = TR("sb_daemon_not_found"); @@ -2667,7 +2706,6 @@ void App::renderStatusBar() // including ones whose toast already faded; an unread dot marks alerts that arrived since // the panel was last opened. { - const float dp = ui::Layout::dpiScale(); auto& notes = ui::Notifications::instance(); ImFont* bellFont = ui::material::Type().iconSmall(); const bool anyHist = notes.hasHistory(); @@ -3994,9 +4032,16 @@ void App::renderSeedMigrationDialog() ImGui::TextWrapped("%s", TR("mig_already_mnemonic")); ImGui::PopStyleColor(); ImGui::Spacing(); - if (ui::material::TactileButton(TR("mig_backup_instead"), ImVec2(200 * dp, 0))) { - close(); - showSeedBackupDialog(); + { + // Size to the label — the fixed 200px clipped the FR/PT/ES/DE/RU translations. + ImFont* bkFont = ui::material::Type().button(); + const float bkW = std::max(200.0f * dp, + bkFont->CalcTextSizeA(bkFont->LegacySize, FLT_MAX, 0, TR("mig_backup_instead")).x + + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp); + if (ui::material::TactileButton(TR("mig_backup_instead"), ImVec2(bkW, 0))) { + close(); + showSeedBackupDialog(); + } } ImGui::SameLine(); if (ui::material::TactileButton(TR("close"), ImVec2(120 * dp, 0))) close(); @@ -4167,8 +4212,12 @@ void App::renderSeedMigrationDialog() ImGui::Spacing(); char cbuf[64]; snprintf(cbuf, sizeof(cbuf), TR("mig_confs"), seed_migration_sweep_confs_); ImGui::TextColored(kMedium, "%s", cbuf); - if (!seed_migration_sweep_txid_.empty()) - ImGui::TextColored(kMedium, "%s%s", TR("mig_txid"), seed_migration_sweep_txid_.c_str()); + if (!seed_migration_sweep_txid_.empty()) { + // A 64-hex txid overflows the fixed 580px card; render it in a wrapping, copyable field + // (same widget the import-key sweep result uses) instead of a raw one-line label. + ImGui::TextColored(kMedium, "%s", TR("mig_txid")); + ui::widgets::AddressCopyField("##migtxid", seed_migration_sweep_txid_); + } if (seed_migration_legacy_remaining_ >= 0.0) { char rbuf[96]; snprintf(rbuf, sizeof(rbuf), TR("mig_remaining"), seed_migration_legacy_remaining_); ImGui::TextColored(kMedium, "%s", rbuf); @@ -4292,39 +4341,319 @@ void App::renderWalletRecoveredDialog() ui::material::OverlayDialogSpec ov; ov.title = TR("wallet_recovered_title"); - ov.p_open = &show_wallet_recovered_dialog_; + // Dismiss (backdrop / [X]) is disabled during Working — files are mid-swap. A null p_open is safe: + // BeginOverlayDialog guards both the close button and backdrop-close on it. + ov.p_open = (recovery_phase_ == RecoveryPhase::Working) ? nullptr : &show_wallet_recovered_dialog_; ov.style = ui::material::OverlayStyle::BlurFloat; - ov.cardWidth = 560.0f; + ov.cardWidth = 620.0f; // wide enough for the two side-by-side choice cards ov.idSuffix = "walletrecovered"; if (!ui::material::BeginOverlayDialog(ov)) return; const float dp = ui::Layout::dpiScale(); + // Size each button to its label (with a modest floor) instead of a magic fixed width, so + // translations of these keys — added additively to res/lang later — can't silently clip. + ImFont* rbf = ui::material::Type().button(); + auto fitBtnW = [&](const char* label) { + return std::max(96.0f * dp, + rbf->CalcTextSizeA(rbf->LegacySize, FLT_MAX, 0, label).x + + ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp); + }; + // A full-width action row: the button, then a dim wrapped one-line explanation of what it does. + auto actionRow = [&](const char* label, const char* sub) -> bool { + const bool clicked = ui::material::TactileButton(label, ImVec2(fitBtnW(label), 0)); + if (sub && sub[0]) { + ImGui::PushFont(ui::material::Type().caption()); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextDisabled("%s", sub); + ImGui::PopTextWrapPos(); + ImGui::PopFont(); + } + return clicked; + }; + // A disclosure that recedes: no filled bar, dim label — so the primary action stays dominant. + auto quietHeader = [&](const char* label) -> bool { + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ui::material::WithAlpha(ui::material::OnSurface(), 20)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ui::material::WithAlpha(ui::material::OnSurface(), 32)); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium()); + const bool open = ImGui::CollapsingHeader(label); + ImGui::PopStyleColor(4); + return open; + }; + // Opt-in daemon log — the same lines that used to be dumped on screen, now behind a quiet disclosure. + auto techDetails = [&]() { + if (!daemon_controller_) return; + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + if (quietHeader(TR("wallet_recovery_details_label"))) { + ImGui::PushFont(ui::material::Type().caption()); + ImGui::PushTextWrapPos(0.0f); + for (const auto& ln : daemon_controller_->recentLines(8)) + ImGui::TextDisabled("%s", ln.c_str()); + ImGui::PopTextWrapPos(); + ImGui::PopFont(); + } + }; + // Funds-safety hero: a shield icon + the reassurance, made the visual focal point of the screen. + auto safetyHero = [&]() { + ImFont* icoF = ui::material::Type().iconLarge(); + ImFont* txtF = ui::material::Type().subtitle1(); + const float rowTop = ImGui::GetCursorPosY(); + ImGui::PushFont(icoF); + ImGui::TextColored(ui::material::SuccessVec4(), ICON_MD_HEALTH_AND_SAFETY); + ImGui::PopFont(); + ImGui::SameLine(); + const float iconH = icoF->LegacySize; + const float textH = txtF ? txtF->LegacySize : ImGui::GetFontSize(); + if (iconH > textH) ImGui::SetCursorPosY(rowTop + (iconH - textH) * 0.5f); + ImGui::PushFont(txtF); + ImGui::TextColored(ui::material::SuccessVec4(), "%s", TR("wallet_recovered_safety")); + ImGui::PopFont(); + }; + // Accent-tinted primary action so the recommended button clearly dominates the quiet disclosures + // (a translucent Primary tint over the glass button — keeps the label readable on any theme). + auto primaryAction = [&](const char* label, const char* sub) -> bool { + ImGui::PushStyleColor(ImGuiCol_Button, ui::material::WithAlpha(ui::material::Primary(), 60)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::material::WithAlpha(ui::material::Primary(), 90)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ui::material::WithAlpha(ui::material::Primary(), 115)); + const bool clicked = actionRow(label, sub); + ImGui::PopStyleColor(3); + return clicked; + }; + // A choice card: icon + title (+ optional "recommended" chip) + wrapped blurb + a full-width button + // pinned to the card bottom. The recommended card gets a gold-tinted fill + border so the choice + // reads at a glance. cardH is precomputed by the caller so side-by-side cards stay equal height. + auto renderCard = [&](const char* id, const char* glyph, const char* title, const char* desc, + const char* btn, bool recommended, float cardW, float cardH) -> bool { + const float pad = ui::Layout::spacingMd(); + ImGui::PushStyleColor(ImGuiCol_ChildBg, + recommended ? ui::material::WithAlpha(ui::material::Primary(), 22) + : ui::material::WithAlpha(ui::material::OnSurface(), 8)); + ImGui::PushStyleColor(ImGuiCol_Border, + recommended ? ui::material::WithAlpha(ui::material::Primary(), 140) + : ui::material::WithAlpha(ui::material::OnSurface(), 28)); + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 6.0f * dp); + ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, 1.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(pad, pad)); + ImGui::BeginChild(id, ImVec2(cardW, cardH), true, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + // Zero implicit item spacing so the only vertical gaps are the explicit Dummy()s below — that + // keeps the caller's measured cardH exact, so the bottom-pinned button never clips. + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.0f, 0.0f)); - ui::material::DialogWarningHeader(TR("wallet_recovered_warn")); - ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); - ImGui::TextWrapped("%s", TR("wallet_recovered_body")); - ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd())); - // Preferred fix when available: REBUILD the wallet database. Plain "Restore original" hands the same - // BDB-inconsistent file back and the daemon just re-salvages it (the cascade); the rebuild produces a - // fresh, consistent copy of every key that the daemon loads cleanly. Copy/rename-only — never deletes. - if (walletRebuildAvailable()) { - if (ui::material::TactileButton(TR("wallet_recovered_rebuild"), ImVec2(280.0f * dp, 0))) { - rebuildWalletDatabase(); // clears show_wallet_recovered_dialog_ + ImGui::PushFont(ui::material::Type().iconMed()); + ImGui::TextColored(recommended ? ui::material::PrimaryVec4() + : ImGui::ColorConvertU32ToFloat4(ui::material::OnSurfaceMedium()), + "%s", glyph); + ImGui::PopFont(); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs())); + + ImGui::PushFont(ui::material::Type().subtitle2()); + ImGui::TextUnformatted(title); + ImGui::PopFont(); + if (recommended) { + ImGui::PushFont(ui::material::Type().caption()); + ImGui::TextColored(ui::material::PrimaryVec4(), "%s", TR("wallet_recovery_recommended")); + ImGui::PopFont(); + } + ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs())); + + ImGui::PushFont(ui::material::Type().caption()); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium()); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextWrapped("%s", desc); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + ImGui::PopFont(); + + // Pin the button to the card bottom so both cards' buttons line up. + const float btnH = ImGui::GetFrameHeight() + 6.0f * dp; + const float remaining = ImGui::GetContentRegionAvail().y - btnH; + if (remaining > 0.0f) ImGui::Dummy(ImVec2(0, remaining)); + bool clicked; + if (recommended) { + ImGui::PushStyleColor(ImGuiCol_Button, ui::material::WithAlpha(ui::material::Primary(), 65)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::material::WithAlpha(ui::material::Primary(), 100)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ui::material::WithAlpha(ui::material::Primary(), 125)); + clicked = ui::material::TactileButton(btn, ImVec2(-FLT_MIN, btnH)); + ImGui::PopStyleColor(3); + } else { + clicked = ui::material::TactileButton(btn, ImVec2(-FLT_MIN, btnH)); + } + ImGui::PopStyleVar(); // ItemSpacing + ImGui::EndChild(); + ImGui::PopStyleVar(3); + ImGui::PopStyleColor(2); + return clicked; + }; + // A quiet clickable text link for the footer (Show me the files / Not now). + auto linkText = [&](const char* label) -> bool { + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium()); + ImGui::TextUnformatted(label); + ImGui::PopStyleColor(); + const bool clicked = ImGui::IsItemClicked(); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + const ImVec2 mn = ImGui::GetItemRectMin(), mx = ImGui::GetItemRectMax(); + ImGui::GetWindowDrawList()->AddLine(ImVec2(mn.x, mx.y), ImVec2(mx.x, mx.y), + ui::material::OnSurface()); + } + return clicked; + }; + + switch (recovery_phase_) { + case RecoveryPhase::Offer: { + safetyHero(); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + ImGui::TextWrapped("%s", TR("wallet_recovered_warn")); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd())); + + // The two real actions as side-by-side CHOICE CARDS — the recommended one gold-tinted, so the + // decision reads at a glance instead of hiding in a menu. Rebuild is the more complete fix; when + // its helper is missing, Restore becomes the single recommended card (never a dead end). + const bool canRebuild = walletRebuildAvailable(); + const float cardGap = ui::Layout::spacingMd(); + const float pad = cardGap; + const float contentW = ImGui::GetContentRegionAvail().x; + const float cardW = canRebuild ? (contentW - cardGap) * 0.5f + : std::min(contentW, 320.0f * dp); + // Fix a shared card height off the taller card body so the two cards line up. Measure title AND + // desc wraps (titles/descs can be multi-line, esp. after translation), with a little width slack. + ImFont* descF = ui::material::Type().caption(); + ImFont* titleF = ui::material::Type().subtitle2(); + const float innerW = std::max(1.0f, cardW - 2.0f * pad - 6.0f * dp); + auto measureH = [&](ImFont* f, const char* s) { + return f->CalcTextSizeA(f->LegacySize, FLT_MAX, innerW, s).y; + }; + const float titleH = std::max(measureH(titleF, TR("wallet_recovery_rebuild_card")), + measureH(titleF, TR("wallet_recovery_restore_card"))); + const float descH = std::max(measureH(descF, TR("wallet_recovery_rebuild_card_desc")), + measureH(descF, TR("wallet_recovery_restore_card_desc"))); + const float cardH = 2.0f * pad + + ui::material::Type().iconMed()->LegacySize + ui::Layout::spacingXs() // icon + gap + + titleH + descF->LegacySize // title + RECOMMENDED chip + + ui::Layout::spacingXs() // gap before desc + + descH + ui::Layout::spacingSm() // desc + gap before button + + ImGui::GetFrameHeight() + 6.0f * dp // button + + ui::Layout::spacingXs(); // small buffer + + if (canRebuild) { + const bool r = renderCard("##rcRepair", ICON_MD_AUTO_FIX_HIGH, TR("wallet_recovery_rebuild_card"), + TR("wallet_recovery_rebuild_card_desc"), TR("wallet_recovery_repair_go"), + true, cardW, cardH); + ImGui::SameLine(0, cardGap); + const bool s = renderCard("##rcRestore", ICON_MD_SETTINGS_BACKUP_RESTORE, TR("wallet_recovery_restore_card"), + TR("wallet_recovery_restore_card_desc"), TR("wallet_recovery_restore_go"), + false, cardW, cardH); + if (r) rebuildWalletDatabase(); + if (s) restoreOriginalWallet(); + } else { + const float indent = (contentW - cardW) * 0.5f; + if (indent > 0.0f) ImGui::Indent(indent); + const bool s = renderCard("##rcRestoreOnly", ICON_MD_SETTINGS_BACKUP_RESTORE, TR("wallet_recovery_restore_card"), + TR("wallet_recovery_restore_card_desc"), TR("wallet_recovery_restore_go"), + true, cardW, cardH); + if (indent > 0.0f) ImGui::Unindent(indent); + if (s) restoreOriginalWallet(); + } + + // Quiet footer: inspect-files link + a clearly-labelled, reversible dismiss (with a tooltip that + // spells out the consequence — plain "Not now" was ambiguous). + ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd())); + if (linkText(TR("wallet_recovered_open_folder"))) + util::Platform::openFolder(util::Platform::getDragonXDataDir()); + ImGui::SameLine(0, ui::Layout::spacingSm()); + ImGui::TextDisabled("\xC2\xB7"); // middle dot separator + ImGui::SameLine(0, ui::Layout::spacingSm()); + if (linkText(TR("wallet_recovery_decide_later"))) { + show_wallet_recovered_dialog_ = false; // non-destructive; reopen from the status bar + recovery_phase_ = RecoveryPhase::Offer; + } + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 22.0f); + ImGui::TextUnformatted(TR("wallet_recovery_decide_later_tip")); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } + // Plain-language "what happens to my files" — replaces the unhelpful raw daemon log on this screen + // (the log is still available in the Console tab for troubleshooting). + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + if (quietHeader(TR("wallet_recovery_files_label"))) { + ImGui::PushFont(ui::material::Type().caption()); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium()); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextWrapped("%s", TR("wallet_recovery_files_detail")); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + ImGui::PopFont(); + } + break; + } + case RecoveryPhase::Working: { + ImGui::TextWrapped("%s", recovery_last_action_rebuild_ ? TR("wallet_rebuild_started") + : TR("wallet_restore_started")); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd())); + ImGui::Text("%s%s", TR("wallet_recovery_working_label"), ui::material::LoadingDots()); + break; + } + case RecoveryPhase::Done: { + safetyHero(); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + ImGui::TextWrapped("%s", recovery_last_action_rebuild_ ? TR("wallet_recovery_success_body") + : TR("wallet_recovery_success_restore")); + // A sev-1 warning (e.g. restored but the node didn't relaunch) carries a specific message. + if (recovery_outcome_sev_ == 1 && !recovery_outcome_msg_.empty()) { + ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs())); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning()); + ImGui::TextWrapped("%s", recovery_outcome_msg_.c_str()); + ImGui::PopStyleColor(); + } + ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd())); + if (ui::material::TactileButton(TR("wallet_recovery_done"), ImVec2(fitBtnW(TR("wallet_recovery_done")), 0))) { + show_wallet_recovered_dialog_ = false; + recovery_phase_ = RecoveryPhase::Offer; + // Clean success — clear the session flag so a later unrelated disconnect doesn't re-raise the + // "repair available" chip/dialog for a wallet that's already fixed. (Warnings keep it set.) + if (recovery_outcome_sev_ == 0) wallet_auto_recovered_ = false; + } + techDetails(); + break; + } + case RecoveryPhase::Failed: { + ImGui::PushFont(ui::material::Type().subtitle1()); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError()); + ImGui::TextWrapped("%s", TR("wallet_recovery_failure_title")); + ImGui::PopStyleColor(); + ImGui::PopFont(); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + ImGui::TextWrapped("%s", TR("wallet_recovery_failure_body")); + if (!recovery_outcome_msg_.empty()) { + ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs())); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError()); + ImGui::TextWrapped("%s", recovery_outcome_msg_.c_str()); + ImGui::PopStyleColor(); + } + ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd())); + // Offer the UNtried option (Restore needs no helper; Rebuild only if its helper exists). + if (recovery_last_action_rebuild_) { + if (actionRow(TR("wallet_recovery_try_other"), TR("wallet_recovered_restore_sub"))) + restoreOriginalWallet(); + } else if (walletRebuildAvailable()) { + if (actionRow(TR("wallet_recovery_try_other"), TR("wallet_recovered_rebuild_sub"))) + rebuildWalletDatabase(); } ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + if (actionRow(TR("wallet_recovered_open_folder"), TR("wallet_recovered_open_folder_sub"))) + util::Platform::openFolder(util::Platform::getDragonXDataDir()); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + if (ui::material::TactileButton(TR("close"), ImVec2(fitBtnW(TR("close")), 0))) { + show_wallet_recovered_dialog_ = false; + recovery_phase_ = RecoveryPhase::Offer; + } + techDetails(); + break; } - // Restore of the untouched original (stops the node, swaps the .bak back over the salvaged copy, - // clears the stale BDB env, restarts). - if (ui::material::TactileButton(TR("wallet_recovered_restore"), ImVec2(260.0f * dp, 0))) { - restoreOriginalWallet(); // clears show_wallet_recovered_dialog_ - } - ImGui::SameLine(); - if (ui::material::TactileButton(TR("wallet_recovered_open_folder"), ImVec2(200.0f * dp, 0))) { - util::Platform::openFolder(util::Platform::getDragonXDataDir()); // manual restore instead - } - ImGui::SameLine(); - if (ui::material::TactileButton(TR("wallet_recovered_dismiss"), ImVec2(150.0f * dp, 0))) { - show_wallet_recovered_dialog_ = false; // acknowledged; keeps the salvaged wallet loaded } + ui::material::EndOverlayDialog(); } @@ -5462,6 +5791,10 @@ void App::renderLoadingOverlay(float contentH) using namespace ui::material; constexpr float kPi = 3.14159265f; + // The wallet-recovery dialog owns the screen while a salvage is pending — suppress the loading + // spinner underneath it so "still loading" and "make a decision" are never the same screen. + if (show_wallet_recovered_dialog_) return; + auto loadElem = [](const char* key, float fb) { float v = ui::schema::UI().drawElement("screens.loading", key).size; return v >= 0 ? v : fb; @@ -5473,12 +5806,17 @@ void App::renderLoadingOverlay(float contentH) ImVec2 wp = ImGui::GetWindowPos(); ImVec2 ws = ImGui::GetWindowSize(); + // Hand-drawn geometry here is in logical px; the fonts drawn between the elements are DPI-baked, + // so every spinner/bar/gap constant must be multiplied by dpiScale or it renders native-tiny and + // the spacing/centering drifts at HiDPI / font_scale > 1. (Font metrics are already scaled.) + const float dpi = ui::Layout::dpiScale(); + // Layout constants float lineH = ImGui::GetTextLineHeightWithSpacing(); - float spinnerR = loadElem("spinner-radius", 18.0f); - float gap = loadElem("vertical-gap", 8.0f); - float barH = loadElem("progress-bar", 6.0f); - float barW = loadElem("progress-width", 260.0f); + float spinnerR = loadElem("spinner-radius", 18.0f) * dpi; + float gap = loadElem("vertical-gap", 8.0f) * dpi; + float barH = loadElem("progress-bar", 6.0f) * dpi; + float barW = loadElem("progress-width", 260.0f) * dpi; float cx = ws.x * 0.5f; // centre X (local coords) // Estimate total block height for vertical centering @@ -5494,8 +5832,8 @@ void App::renderLoadingOverlay(float contentH) // ------------------------------------------------------------------- { float r = spinnerR; - float thick = loadElem("spinner-thickness", 2.5f); - ImVec2 sc(wp.x + cx, curY + r + 2.0f); + float thick = loadElem("spinner-thickness", 2.5f) * dpi; + ImVec2 sc(wp.x + cx, curY + r + 2.0f * dpi); // Background ring (dim) dl->PathArcTo(sc, r, 0.0f, kPi * 2.0f, 48); @@ -5508,7 +5846,63 @@ void App::renderLoadingOverlay(float contentH) dl->PathStroke(ui::schema::UI().resolveColor("var(--spinner-active)", IM_COL32(255, 218, 0, 200)), 0, thick); - curY += r * 2.0f + gap + 4.0f; + curY += r * 2.0f + gap + 4.0f * dpi; + } + + // ------------------------------------------------------------------- + // Post-repair rescan — a calm, recovery-aware screen (not the generic "daemon stuck / RPC timeout / + // restart daemon" text). The daemon was intentionally restarted with a full rescan, which + // legitimately takes minutes and won't answer RPC yet; reassure + show elapsed and the growing size. + // ------------------------------------------------------------------- + // Only while the daemon is actually alive-and-rescanning. If it EXITED (crashed for a reason distinct + // from the wallet file), fall through to the normal error/stall UI — tryConnect() clears the flag, but + // gate here too so a lingering frame never shows "everything's fine" over a dead daemon. + if (post_recovery_rescan_ && + !(daemon_controller_ && daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error)) { + if (post_recovery_rescan_since_ <= 0.0) post_recovery_rescan_since_ = ImGui::GetTime(); + ImFont* titleF = Type().subtitle1(); if (!titleF) titleF = ImGui::GetFont(); + ImFont* capF = Type().caption(); if (!capF) capF = ImGui::GetFont(); + + auto centeredLine = [&](ImFont* f, ImU32 col, const char* s, float wrapW) { + ImVec2 ts = f->CalcTextSizeA(f->LegacySize, FLT_MAX, wrapW, s); + float x = (wrapW > 0.0f) ? (wp.x + cx - wrapW * 0.5f) : (wp.x + cx - ts.x * 0.5f); + dl->AddText(f, f->LegacySize, ImVec2(x, curY), col, s, nullptr, wrapW); + curY += ts.y + gap; + }; + + centeredLine(titleF, IM_COL32(230, 210, 90, 235), TR("wallet_recovery_rescan_title"), 0.0f); + float wrapW = ws.x * 0.8f; if (wrapW > 620.0f * dpi) wrapW = 620.0f * dpi; + centeredLine(capF, IM_COL32(200, 200, 200, 220), TR("wallet_recovery_rescan_body"), wrapW); + + // Elapsed + the growing wallet size — concrete "it's working" feedback while RPC is silent. Recompute + // at most once a second (the display granularity) to avoid a stat()+format on every frame. + int secs = (int)(ImGui::GetTime() - post_recovery_rescan_since_); if (secs < 0) secs = 0; + static int s_lastSec = -1; + static std::string s_elapsed, s_size; + if (secs != s_lastSec) { + s_lastSec = secs; + char clock[16]; snprintf(clock, sizeof(clock), "%d:%02d", secs / 60, secs % 60); + char ebuf[96]; snprintf(ebuf, sizeof(ebuf), TR("wallet_recovery_rescan_elapsed"), clock); + s_elapsed = ebuf; + const std::string walletPath = util::Platform::getDragonXDataDir() + "/" + + ((settings_ && !settings_->getActiveWalletFile().empty()) + ? settings_->getActiveWalletFile() : std::string("wallet.dat")); + uint64_t wsz = util::Platform::getFileSize(walletPath); + if (wsz > 0) { char sbuf[96]; snprintf(sbuf, sizeof(sbuf), TR("wallet_recovery_rescan_size"), + util::Platform::formatFileSize(wsz).c_str()); s_size = sbuf; } + else s_size.clear(); + } + curY += gap * 0.5f; + centeredLine(capF, IM_COL32(150, 150, 150, 220), s_elapsed.c_str(), 0.0f); + if (!s_size.empty()) centeredLine(capF, IM_COL32(130, 130, 130, 210), s_size.c_str(), 0.0f); + + // Backstop for a genuinely slow (but still running) rescan: after several minutes add a gentle + // "it's safe to leave running / watch the Console" line — never the scary "restart the node". + if (secs > 600) { + curY += gap * 0.5f; + centeredLine(capF, IM_COL32(150, 150, 150, 200), TR("wallet_recovery_rescan_slow"), wrapW); + } + return; // skip the generic status / stall / error / daemon-log sections } // ------------------------------------------------------------------- @@ -5518,11 +5912,23 @@ void App::renderLoadingOverlay(float contentH) const char* statusText = connection_status_.c_str(); ImFont* font = Type().subtitle1(); if (!font) font = ImGui::GetFont(); - ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0.0f, statusText); - dl->AddText(font, font->LegacySize, - ImVec2(wp.x + cx - ts.x * 0.5f, curY), - IM_COL32(220, 220, 220, 255), statusText); - curY += ts.y + gap; + // Short statuses (the common case: "Connected", "Starting daemon") stay centered; long ones + // (a full dir_error path, a libcurl connect error) wrap to a clamped box instead of running + // off both edges of the overlay — the same clamp the daemon-error blocks below use. + float wrapW = ws.x * 0.8f; if (wrapW > 640.0f * dpi) wrapW = 640.0f * dpi; + ImVec2 full = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0.0f, statusText); + if (full.x <= wrapW) { + dl->AddText(font, font->LegacySize, + ImVec2(wp.x + cx - full.x * 0.5f, curY), + IM_COL32(220, 220, 220, 255), statusText); + curY += full.y + gap; + } else { + ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, wrapW, statusText); + dl->AddText(font, font->LegacySize, + ImVec2(wp.x + cx - wrapW * 0.5f, curY), + IM_COL32(220, 220, 220, 255), statusText, nullptr, wrapW); + curY += ts.y + gap; + } } // ------------------------------------------------------------------- @@ -5546,7 +5952,7 @@ void App::renderLoadingOverlay(float contentH) float progress = state_.sync.witness_progress; if (progress < 0.0f) progress = 0.0f; if (progress > 1.0f) progress = 1.0f; - float barRadius = loadElem("progress-bar", 3.0f); + float barRadius = loadElem("progress-bar", 3.0f) * dpi; float barX = wp.x + cx - barW * 0.5f; ImVec2 barMin(barX, curY); ImVec2 barMax(barX + barW, curY + barH); @@ -5583,7 +5989,7 @@ void App::renderLoadingOverlay(float contentH) // ------------------------------------------------------------------- if (state_.connected && state_.sync.syncing) { float progress = static_cast(state_.sync.verification_progress); - float barRadius = loadElem("progress-bar", 3.0f); + float barRadius = loadElem("progress-bar", 3.0f) * dpi; float barX = wp.x + cx - barW * 0.5f; ImVec2 barMin(barX, curY); @@ -5647,7 +6053,7 @@ void App::renderLoadingOverlay(float contentH) // Indeterminate progress bar float encBarW = barW * 0.6f; - float encBarH = 4.0f; + float encBarH = 4.0f * dpi; float encBarX = wp.x + cx - encBarW * 0.5f; dl->AddRectFilled(ImVec2(encBarX, curY), ImVec2(encBarX + encBarW, curY + encBarH), IM_COL32(255, 255, 255, 20), 2.0f); @@ -5664,7 +6070,8 @@ void App::renderLoadingOverlay(float contentH) // 3c. Daemon crash error message // ------------------------------------------------------------------- if (daemon_controller_ && - daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error) { + daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error && + !wallet_auto_recovered_) { // a salvage is NOT a crash — it has its own recovery dialog curY += gap; ImFont* capFont = Type().caption(); if (!capFont) capFont = ImGui::GetFont(); @@ -5679,50 +6086,25 @@ void App::renderLoadingOverlay(float contentH) IM_COL32(255, 90, 90, 255), errTitle); curY += ts.y + gap * 0.5f; - // Wallet auto-recovery/salvage takes over the error card: a concise message + prominent one-click - // actions, RIGHT HERE in the overlay the user is looking at (the separate dialog can be occluded - // by this full-frame overlay while the node is down). Skip the verbose daemon dump so the buttons - // stay on-screen. Same handlers as the dialog. - if (wallet_auto_recovered_) { - const char* msg = TR("wallet_recovered_warn"); - float wrapW = ws.x * 0.8f; if (wrapW > 640.0f) wrapW = 640.0f; - ImVec2 ms = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, msg); - dl->AddText(capFont, capFont->LegacySize, ImVec2(wp.x + cx - wrapW * 0.5f, curY), - IM_COL32(230, 210, 210, 235), msg, nullptr, wrapW); - curY += ms.y + gap; - const float dpi = ui::Layout::dpiScale(); - const float bw = 340.0f * dpi; - auto placeBtn = [&](const char* label) -> bool { - ImGui::SetCursorScreenPos(ImVec2(wp.x + cx - bw * 0.5f, curY)); - const bool clicked = ui::material::TactileButton(label, ImVec2(bw, 0)); - curY = ImGui::GetItemRectMax().y + gap * 0.4f; - return clicked; - }; - if (walletRebuildAvailable() && placeBtn(TR("wallet_recovered_rebuild"))) rebuildWalletDatabase(); - if (placeBtn(TR("wallet_recovered_restore"))) restoreOriginalWallet(); - if (placeBtn(TR("wallet_recovered_open_folder"))) - util::Platform::openFolder(util::Platform::getDragonXDataDir()); - } else { - // Error details (wrapped) — full diagnostic info. - const std::string& errDetail = daemon_controller_->lastError(); - if (!errDetail.empty()) { - float wrapW = ws.x * 0.8f; - if (wrapW > 700.0f) wrapW = 700.0f; - ImVec2 es = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, errDetail.c_str()); - dl->AddText(capFont, capFont->LegacySize, - ImVec2(wp.x + cx - wrapW * 0.5f, curY), - IM_COL32(255, 180, 180, 220), errDetail.c_str(), nullptr, wrapW); - curY += es.y + gap; - } - // Crash count hint - if (daemon_controller_->crashCount() >= 3) { - const char* hint = "Use Settings > Restart Daemon to try again"; - ImVec2 hs2 = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, hint); - dl->AddText(capFont, capFont->LegacySize, - ImVec2(wp.x + cx - hs2.x * 0.5f, curY), - IM_COL32(200, 200, 200, 180), hint); - curY += hs2.y + gap; - } + // Error details (wrapped) — full diagnostic info for a genuine (non-recovery) crash. + const std::string& errDetail = daemon_controller_->lastError(); + if (!errDetail.empty()) { + float wrapW = ws.x * 0.8f; + if (wrapW > 700.0f * dpi) wrapW = 700.0f * dpi; + ImVec2 es = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, errDetail.c_str()); + dl->AddText(capFont, capFont->LegacySize, + ImVec2(wp.x + cx - wrapW * 0.5f, curY), + IM_COL32(255, 180, 180, 220), errDetail.c_str(), nullptr, wrapW); + curY += es.y + gap; + } + // Crash count hint + if (daemon_controller_->crashCount() >= 3) { + const char* hint = "Use Settings > Restart Daemon to try again"; + ImVec2 hs2 = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, hint); + dl->AddText(capFont, capFont->LegacySize, + ImVec2(wp.x + cx - hs2.x * 0.5f, curY), + IM_COL32(200, 200, 200, 180), hint); + curY += hs2.y + gap; } } @@ -5757,7 +6139,7 @@ void App::renderLoadingOverlay(float contentH) snprintf(stallBody, sizeof(stallBody), TR("loading_stall_body"), (float)(ImGui::GetTime() - connect_stall_since_)); float wrapW = ws.x * 0.8f; - if (wrapW > 640.0f) wrapW = 640.0f; + if (wrapW > 640.0f * dpi) wrapW = 640.0f * dpi; ImVec2 bs = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, stallBody); dl->AddText(capFont, capFont->LegacySize, ImVec2(wp.x + cx - wrapW * 0.5f, curY), @@ -5778,19 +6160,19 @@ void App::renderLoadingOverlay(float contentH) // ------------------------------------------------------------------- // 4. Daemon output snippet (last few lines, if embedded) // ------------------------------------------------------------------- - if (daemon_controller_) { + if (daemon_controller_ && !wallet_auto_recovered_) { // recovery moves the log behind the dialog's disclosure auto lines = daemon_controller_->recentLines(8); if (!lines.empty()) { curY += gap; float panelW = ws.x * 0.85f; - if (panelW > 900.0f) panelW = 900.0f; + if (panelW > 900.0f * dpi) panelW = 900.0f * dpi; float panelX = wp.x + cx - panelW * 0.5f; - float panelPad = 8.0f; + float panelPad = 8.0f * dpi; ImFont* capFont = Type().caption(); if (!capFont) capFont = ImGui::GetFont(); - float panelLineH = capFont->LegacySize + 4.0f; + float panelLineH = capFont->LegacySize + 4.0f * dpi; float panelContentH = panelPad * 2.0f + panelLineH * (float)lines.size(); ImVec2 panelMin(panelX, curY); diff --git a/src/app.h b/src/app.h index a9fde2d..dbba9a3 100644 --- a/src/app.h +++ b/src/app.h @@ -906,6 +906,20 @@ private: bool wallet_auto_recovered_ = false; // a salvage happened this session bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog + // The recovery dialog is the ONE authoritative surface: it stays open through the async rebuild/ + // restore, driven Offer → Working → Done/Failed (pumpWalletRestore sets the outcome). Presentation + // only — the fund-safety file ops in rebuildWalletDatabase()/restoreOriginalWallet() are unchanged. + enum class RecoveryPhase { Offer, Working, Done, Failed }; + RecoveryPhase recovery_phase_ = RecoveryPhase::Offer; + int recovery_outcome_sev_ = 0; // 0 ok / 1 warn / 2 error, set at Done/Failed + std::string recovery_outcome_msg_; // honest result string for the Done/Failed body + bool recovery_last_action_rebuild_ = false; // which handler ran (for "try the other option") + // After a successful repair the daemon restarts with a full rescan — minutes long, and it won't + // answer RPC yet. This makes the loading overlay show a calm "finishing your wallet repair" screen + // (instead of the generic "daemon stuck / RPC timeout / restart daemon" text) and suppresses the + // daemon-crash toast. Set on repair success; cleared on connect (onConnected). + bool post_recovery_rescan_ = false; + double post_recovery_rescan_since_ = 0.0; // stamped on first overlay frame (ImGui::GetTime) // "Restore original wallet" background op: worker sets these under the mutex, pumpWalletRestore() // (main thread) shows the result. 0 = success, 1 = warning, 2 = error. std::mutex wallet_restore_mutex_; diff --git a/src/app_network.cpp b/src/app_network.cpp index f24e1b2..1f8ed88 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -243,7 +243,7 @@ void App::detectWalletAutoRecovery() wallet_auto_recovered_ = true; wallet_auto_recovered_warned_ = true; show_wallet_recovered_dialog_ = true; - ui::Notifications::instance().error(TR("wallet_recovered_notify"), 30.0f); + ui::Notifications::instance().info(TR("wallet_recovered_notify"), 30.0f); // calm, not red — coins are safe VERBOSE_LOGF("[recovery] Daemon auto-recovered/salvaged wallet.dat — surfacing the recovery dialog\n"); } @@ -408,9 +408,12 @@ void App::tryConnect() // "stuck connecting" while the node silently died-and-respawned. Surface each new crash once. const int crashes = daemon_controller_->crashCount(); if (crashes > daemon_last_seen_crashes_) { - daemon_last_seen_crashes_ = crashes; + daemon_last_seen_crashes_ = crashes; // consume it either way, so it can't toast later const std::string detail = daemon_controller_->lastError(); - if (!detail.empty()) { + // Suppress the scary "dragonxd exited unexpectedly" toast during recovery: the stop after a + // salvage, and the intentional restart-with-rescan after a repair, are both EXPECTED here and + // owned by the recovery UI (dialog / calm rescan overlay). + if (!detail.empty() && !wallet_auto_recovered_ && !post_recovery_rescan_) { connection_status_ = TR("sb_daemon_start_failed"); ui::Notifications::instance().error(detail, 30.0f); } @@ -527,6 +530,15 @@ void App::tryConnect() VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt); if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) { + // A repair completed and we restarted with a full rescan, but the fresh daemon has + // now EXITED — a fault distinct from the wallet file (corrupt block index, disk full, + // OOM). Drop out of the calm "finishing repair" state so this surfaces as a normal + // daemon failure (reindex offer / crash toast / restart) instead of silently freezing + // the reconnect loop on a reassuring "don't restart" screen with no way forward. + if (post_recovery_rescan_) { + post_recovery_rescan_ = false; + wallet_auto_recovered_ = false; // repair done; the original-salvage hold is over + } // If the node aborted because its BLOCK DATABASE is unreadable (a daemon-vs-chaindata // format mismatch after an update, or a corrupt index), crash-restarting just repeats // the same abort — and each attempt reloads the whole index (wasteful). Detect it once @@ -597,6 +609,11 @@ void App::onConnected() state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications connect_stall_since_ = 0.0; // connected — clear the "taking too long" clock + // A repair's rescan finished and connected — retire the recovery session so its crash-toast/error-card + // suppression and status-chip hold can't persist forever. (Only when we were mid-post-repair-rescan; + // a pre-repair salvaged-daemon connect keeps the flag so the recovery dialog/chip stay available.) + if (post_recovery_rescan_) wallet_auto_recovered_ = false; + post_recovery_rescan_ = false; // repair's post-restart rescan is past the RPC-less phase now daemon_start_error_shown_ = false; daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too) connection_status_ = TR("connected"); @@ -4575,7 +4592,12 @@ void App::restoreOriginalWallet() ui::Notifications::instance().warning(TR("wallet_restore_busy")); return; } - show_wallet_recovered_dialog_ = false; + // Keep the recovery dialog OPEN and drive it into the Working phase — it shows progress and the + // honest outcome in place (pumpWalletRestore flips it to Done/Failed). Presentation only; every + // file-safety step below is unchanged. + show_wallet_recovered_dialog_ = true; + recovery_phase_ = RecoveryPhase::Working; + recovery_last_action_rebuild_ = false; { std::lock_guard lk(wallet_restore_mutex_); wallet_restore_done_ = false; } daemon_restarting_ = true; // gate the reconnect loop while we swap files connection_status_ = TR("sb_restarting_daemon"); @@ -4684,6 +4706,19 @@ void App::pumpWalletRestore() if (wallet_restore_done_) { done = true; sev = wallet_restore_severity_; msg = wallet_restore_msg_; wallet_restore_done_ = false; } } if (!done) return; + // Primary outcome channel: if the recovery dialog is still up (Working), flip it to Done/Failed in + // place with the real result. The toast below stays as the secondary echo for a dismissed/alt-tabbed + // user. sev 2 = failed (op discarded, nothing changed); sev 0/1 = done (1 carries a warning message). + if (show_wallet_recovered_dialog_ && recovery_phase_ == RecoveryPhase::Working) { + recovery_outcome_sev_ = sev; + recovery_outcome_msg_ = msg; + recovery_phase_ = (sev == 2) ? RecoveryPhase::Failed : RecoveryPhase::Done; + // Clean success (sev 0) → the daemon is now restarting with a full rescan. Flag it so the loading + // overlay shows a calm "finishing repair" screen (not the scary generic stall) until it connects. + // NOT for sev 1 (a warning like "node didn't restart") — nothing is rescanning then, and the Done + // dialog already shows that message. Timestamp is stamped on the first overlay frame. + if (sev == 0) { post_recovery_rescan_ = true; post_recovery_rescan_since_ = 0.0; } + } if (sev == 2) ui::Notifications::instance().error(msg, 25.0f); else if (sev == 1) ui::Notifications::instance().warning(msg, 20.0f); else ui::Notifications::instance().success(msg.empty() ? TR("wallet_restore_ok") : msg, 12.0f); @@ -4705,7 +4740,10 @@ static std::string findWalletRebuildHelper() const std::string p = d + "/" + exe; if (fs::exists(p, ec)) return p; } - return {}; + // Not sitting next to the app/daemon — but a self-contained exe carries it embedded. Extract it on + // demand (first-run param extraction is gated on needsParamsExtraction(), so it may never have run + // on a machine that already had the Sapling params). Returns "" on non-embedded builds. + return dragonx::resources::ensureWalletRebuildHelperExtracted(); } bool App::walletRebuildAvailable() const { return !findWalletRebuildHelper().empty(); } @@ -4724,7 +4762,11 @@ void App::rebuildWalletDatabase() const std::string helper = findWalletRebuildHelper(); if (helper.empty()) { ui::Notifications::instance().error(TR("wallet_rebuild_no_helper"), 15.0f); return; } - show_wallet_recovered_dialog_ = false; + // Keep the recovery dialog OPEN through the rebuild (Working → Done/Failed in place). Presentation + // only; the run-helper → verify-before-swap → copy/rename-never-delete steps below are unchanged. + show_wallet_recovered_dialog_ = true; + recovery_phase_ = RecoveryPhase::Working; + recovery_last_action_rebuild_ = true; { std::lock_guard lk(wallet_restore_mutex_); wallet_restore_done_ = false; } daemon_restarting_ = true; connection_status_ = TR("sb_restarting_daemon"); diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index d0fcecd..c90f996 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1194,15 +1194,50 @@ void I18n::loadBuiltinEnglish() strings_["block_db_reindex_started"] = "Rebuilding the block database from your blocks — this can take a while."; // Wallet auto-recovery warning (the node moved wallet.dat aside and loaded a salvaged copy). - strings_["wallet_recovered_title"] = "Your wallet was auto-recovered"; - strings_["wallet_recovered_warn"] = "The node moved your wallet aside and loaded a salvaged copy."; - strings_["wallet_recovered_body"] = "On startup the node decided your wallet.dat looked damaged and recovered it automatically. Your ORIGINAL wallet was NOT deleted — it was renamed to \"wallet..bak\" in your data folder, and a salvaged copy is loaded now.\n\nThe salvaged copy may be incomplete, so the balance shown here could be wrong — don't treat it as final.\n\nThis is often a false alarm caused by leftover database files (e.g. after moving the wallet between machines). To restore your original: quit the wallet, then in the data folder rename the current wallet.dat aside, rename \"wallet..bak\" back to \"wallet.dat\", delete the \"database\" folder and any \"__db.*\" files, and reopen."; - strings_["wallet_recovered_open_folder"] = "Open data folder"; - strings_["wallet_recovered_dismiss"] = "Keep salvaged copy"; - strings_["wallet_recovered_restore"] = "Restore original wallet"; - strings_["wallet_recovered_notify"] = "The node recovered your wallet and moved the original to a .bak — your shown balance may be incomplete. See the prompt to restore it."; + strings_["wallet_recovered_title"] = "Your wallet file needs a quick repair"; + strings_["wallet_recovered_safety"] = "Your coins are safe."; + strings_["wallet_recovered_warn"] = "When the app started, it found that your wallet file didn't pass its consistency check — this usually happens after an app update or an unclean shutdown. The app already protected your data: it set the old file aside and loaded a repaired copy so you're not stuck."; + strings_["wallet_recovered_body"] = "Nothing has been deleted. Your original wallet is still saved on your computer as a dated backup file, and every option below only copies or renames files — it never erases one. Your keys are never regenerated, only re-read.\n\nThe repaired copy that's loaded now may be missing a few recent transactions, so your balance can look a little low until you finish below and it re-scans."; + strings_["wallet_recovered_open_folder"] = "Show me the files"; + strings_["wallet_recovered_open_folder_sub"] = "Opens the wallet data folder so you can inspect the backup files yourself — nothing is changed."; + strings_["wallet_recovered_dismiss"] = "Not now — keep the repaired copy"; + strings_["wallet_recovered_dismiss_sub"] = "No files change. You can reopen this anytime from the status bar; your original stays safely backed up either way."; + strings_["wallet_recovered_restore"] = "Restore the original file instead"; + strings_["wallet_recovered_restore_sub"] = "Puts your largest untouched backup back in place, verbatim, then re-scans — slightly faster, but only as complete as that one file was. Your current file is kept as a dated backup either way."; + strings_["wallet_recovered_notify"] = "Your wallet file needed a repair — your original was safely backed up. Open the app to review your options."; + // In-dialog recovery lifecycle (Offer → Working → Done/Failed) + disclosures. + strings_["wallet_recovery_working_label"] = "Working"; + strings_["wallet_recovery_done"] = "Done"; + strings_["wallet_recovery_other_options"] = "Other options"; + strings_["wallet_recovery_details_label"] = "Show technical details"; + strings_["wallet_recovery_try_other"] = "Try the other option"; + strings_["wallet_recovery_success_body"] = "The app loaded your repaired wallet and is re-scanning to total your balance — this can take a few minutes. It reads every record it can, but on rare damaged files it may recover slightly fewer — or occasionally more — addresses than before. Once the re-scan finishes, check your balance and history look right."; + strings_["wallet_recovery_success_restore"] = "Your largest backup file is back in place and loading now, and the app is re-scanning. It's restored verbatim, so it's exactly as complete as that file was — check your balance once the re-scan finishes."; + strings_["wallet_recovery_failure_title"] = "The repair didn't go through"; + strings_["wallet_recovery_failure_body"] = "The repair ran, but its result didn't pass verification (it couldn't be read, or had no addresses), so it was discarded automatically before it ever replaced anything. Your wallet is exactly as it was before you clicked — nothing on disk changed."; + strings_["wallet_recovery_whats_happened"] = "What happened?"; + // Choice-card layout: two side-by-side cards (recommended one highlighted) + quiet footer links. + strings_["wallet_recovery_rebuild_card"] = "Repair automatically"; + strings_["wallet_recovery_restore_card"] = "Restore original"; + strings_["wallet_recovery_rebuild_card_desc"] = "Reads every recoverable record into a clean file, then restarts. The most thorough option."; + strings_["wallet_recovery_restore_card_desc"] = "Puts your largest untouched backup back, verbatim \xE2\x80\x94 only as complete as that file was."; + strings_["wallet_recovery_recommended"] = "RECOMMENDED"; + strings_["wallet_recovery_repair_go"] = "Repair"; + strings_["wallet_recovery_restore_go"] = "Restore"; + strings_["wallet_recovery_notnow_short"] = "Not now"; + strings_["wallet_recovery_decide_later"] = "Decide later"; + strings_["wallet_recovery_decide_later_tip"] = "Closes this and keeps the copy that's loaded now. Nothing is changed, and you can repair anytime \xE2\x80\x94 the status bar keeps a \xE2\x80\x9CWallet repair available\xE2\x80\x9D link."; + strings_["wallet_recovery_files_label"] = "What happens to my files?"; + strings_["wallet_recovery_files_detail"] = "Your original wallet is still here \xE2\x80\x94 the app renamed it to a dated backup (wallet..bak) in your data folder and hasn't deleted anything. \xE2\x80\x9CRepair\xE2\x80\x9D reads every record from your fullest wallet into a brand-new clean file. \xE2\x80\x9CRestore\xE2\x80\x9D copies your largest backup back exactly as it is. The copy loaded right now is kept as a backup too."; + // Post-repair rescan screen (shown while the node restarts + re-scans, before it answers RPC). + strings_["wallet_recovery_rescan_title"] = "Finishing your wallet repair"; + strings_["wallet_recovery_rescan_body"] = "Re-scanning the blockchain to rebuild your balance and transaction history. This can take several minutes — you don't need to do anything, and please don't restart the node while it's running."; + strings_["wallet_recovery_rescan_elapsed"] = "Working for %s"; + strings_["wallet_recovery_rescan_size"] = "Rebuilt wallet is now %s and still filling in"; + strings_["wallet_recovery_rescan_slow"] = "This is taking longer than usual, which is normal for a large wallet. It's safe to leave it running — you can watch progress under Advanced \xE2\x96\xB8 Console."; + strings_["sb_finishing_repair"] = "Finishing wallet repair — re-scanning…"; // One-click "Restore original wallet" flow. - strings_["wallet_restore_started"] = "Restoring your original wallet and restarting the node…"; + strings_["wallet_restore_started"] = "Restoring your original file — please don't close this window."; strings_["wallet_restore_busy"] = "The node is busy restarting — try again in a moment."; strings_["wallet_restore_ok"] = "Original wallet restored. The node is loading it now."; strings_["wallet_restore_no_backup"] = "Couldn't find a wallet..bak to restore. Nothing was changed."; @@ -1212,8 +1247,9 @@ void I18n::loadBuiltinEnglish() strings_["wallet_restore_copy_failed"] = "Couldn't install the backup wallet; your current wallet was left in place."; strings_["wallet_restore_no_restart"] = "Your original wallet was restored, but the node didn't restart — start it from Settings."; // One-click "Rebuild wallet database" flow (fixes a BDB-inconsistent wallet that keeps getting salvaged). - strings_["wallet_recovered_rebuild"] = "Rebuild wallet database (recommended)"; - strings_["wallet_rebuild_started"] = "Rebuilding your wallet database and restarting the node…"; + strings_["wallet_recovered_rebuild"] = "Repair automatically (recommended)"; + strings_["wallet_recovered_rebuild_sub"] = "Re-reads every recoverable record from your fullest wallet file and writes a clean new one, then restarts. The most thorough option — your current file is kept as a dated backup either way."; + strings_["wallet_rebuild_started"] = "Repairing your wallet file — please don't close this window."; strings_["wallet_rebuild_ok"] = "Wallet database rebuilt — the node is loading it and rescanning for your balance."; strings_["wallet_rebuild_no_helper"] = "The wallet-rebuild helper isn't available in this build. Use Restore, or rebuild manually."; strings_["wallet_rebuild_no_source"] = "Couldn't find a readable wallet to rebuild. Nothing was changed."; @@ -1345,7 +1381,7 @@ void I18n::loadBuiltinEnglish() strings_["sb_daemon_crashed"] = "Daemon crashed %d times"; strings_["sb_daemon_start_failed"] = "Couldn't start dragonxd"; strings_["sb_block_db_unreadable"] = "Block database unreadable — rebuild required"; - strings_["sb_wallet_needs_recovery"] = "Wallet needs recovery — see the prompt"; + strings_["sb_wallet_needs_recovery"] = "Wallet repair available"; // 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";