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

@@ -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<std::mutex> 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<std::mutex> lk(wallet_restore_mutex_); wallet_restore_done_ = false; }
daemon_restarting_ = true;
connection_status_ = TR("sb_restarting_daemon");