feat(recovery): redesign the wallet auto-recovery flow

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-10 22:11:26 -05:00
parent 001e85ac1a
commit 5edbe8a276
4 changed files with 604 additions and 130 deletions

View File

@@ -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<float>(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);