feat(wallet): live progress modal for wallet switching

The "Stop the running node?" confirm modal now stays open through the entire
switch — stop → wait-for-exit → start → reconnect — showing a live phase, and
auto-closes the moment the new node connects. This turns the up-to-a-minute
graceful-shutdown wait from an apparent freeze into visible progress.

- WalletSwitchPhase (Stopping/Starting/Reconnecting/Failed) + atomic phase and
  dialog-open flags; the worker advances the phase, onConnected closes the modal,
  and a failed switch shows the accurate reason with a Close button.
- Owned switches (no confirm) also show the progress modal directly.
- "Continue in background" escape hatch so a long rescan / a hung startup never
  traps the user (the switch keeps running; a toast reports the result).
- isWalletSwitchInProgress() keeps the frame loop redrawing so the phase text and
  spinner animate while otherwise idle.
- i18n (EN + 8 languages, additive) + a modal-switch-progress sweep surface.

State machine adversarially verified 6/6 (thread-safety, no stuck modal,
confirm→progress transition, owned/unowned, no phase leak, redraw scoping).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 20:56:53 -05:00
parent 02839dc415
commit 88091bd77c
14 changed files with 161 additions and 29 deletions

View File

@@ -3863,35 +3863,70 @@ void App::renderAntivirusHelpDialog()
void App::renderSwitchStopDaemonDialog()
{
if (!show_switch_stop_daemon_confirm_) return;
const bool confirm = show_switch_stop_daemon_confirm_;
const bool progress = wallet_switch_dialog_open_.load();
if (!confirm && !progress) return;
const auto phase = static_cast<WalletSwitchPhase>(wallet_switch_phase_.load());
const bool failed = progress && phase == WalletSwitchPhase::Failed;
ui::material::OverlayDialogSpec ov;
ov.title = TR("switch_stopnode_title");
ov.p_open = &show_switch_stop_daemon_confirm_; // the X / backdrop click acts as Cancel
ov.title = confirm ? TR("switch_stopnode_title")
: failed ? TR("switch_progress_failed_title")
: TR("switch_progress_title");
// A confirm can be dismissed via X/backdrop (= cancel); a running switch cannot (stay until it's up).
ov.p_open = confirm ? &show_switch_stop_daemon_confirm_ : nullptr;
ov.style = ui::material::OverlayStyle::BlurFloat;
ov.cardWidth = 520.0f; ov.idSuffix = "switchstopnode";
if (!ui::material::BeginOverlayDialog(ov)) {
// Closed via X/backdrop — treat as cancel: drop the pending target.
if (!show_switch_stop_daemon_confirm_) pending_switch_wallet_file_.clear();
if (confirm && !show_switch_stop_daemon_confirm_) pending_switch_wallet_file_.clear(); // X = cancel
return;
}
const float dp = ui::Layout::dpiScale();
ui::material::DialogWarningHeader(TR("switch_stopnode_warn"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::TextWrapped("%s", TR("switch_stopnode_body"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
if (ui::material::TactileButton(TR("switch_stopnode_confirm"), ImVec2(200.0f * dp, 0))) {
const std::string w = pending_switch_wallet_file_;
pending_switch_wallet_file_.clear();
show_switch_stop_daemon_confirm_ = false;
switchToWallet(w, /*stopDaemonConfirmed=*/true); // now proceeds to stop + wait + restart
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(110.0f * dp, 0))) {
pending_switch_wallet_file_.clear();
show_switch_stop_daemon_confirm_ = false;
if (confirm) {
ui::material::DialogWarningHeader(TR("switch_stopnode_warn"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::TextWrapped("%s", TR("switch_stopnode_body"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
if (ui::material::TactileButton(TR("switch_stopnode_confirm"), ImVec2(200.0f * dp, 0))) {
const std::string w = pending_switch_wallet_file_;
pending_switch_wallet_file_.clear();
show_switch_stop_daemon_confirm_ = false;
switchToWallet(w, /*stopDaemonConfirmed=*/true); // opens the live-progress phase below
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(110.0f * dp, 0))) {
pending_switch_wallet_file_.clear();
show_switch_stop_daemon_confirm_ = false;
}
} else if (failed) {
ui::material::DialogWarningHeader(TR("switch_progress_failed_title"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::TextWrapped("%s", wallet_switch_error_.c_str());
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
if (ui::material::TactileButton(TR("close"), ImVec2(110.0f * dp, 0))) {
wallet_switch_dialog_open_.store(false);
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::None));
wallet_switch_error_.clear();
}
} else {
// Live progress: an animated phase line, kept redrawing by isWalletSwitchInProgress().
const char* status =
phase == WalletSwitchPhase::Starting ? TR("switch_progress_starting")
: phase == WalletSwitchPhase::Reconnecting ? TR("switch_progress_reconnecting")
: TR("switch_progress_stopping");
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ui::material::Type().text(ui::material::TypeStyle::Subtitle1,
(std::string(status) + ui::material::LoadingDots()).c_str());
if (phase == WalletSwitchPhase::Stopping) {
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::TextWrapped("%s", TR("switch_progress_hint"));
}
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
// Escape hatch — the switch keeps running in the background (a toast reports the result).
if (ui::material::TactileButton(TR("switch_progress_background"), ImVec2(220.0f * dp, 0)))
wallet_switch_dialog_open_.store(false);
}
ui::material::EndOverlayDialog();

View File

@@ -139,6 +139,9 @@ public:
* @brief Whether we are in the shutdown phase
*/
bool isShuttingDown() const { return shutting_down_; }
// True while the wallet-switch progress modal is open — keeps the frame loop redrawing so its live
// phase/spinner update in real time even when the app is otherwise idle.
bool isWalletSwitchInProgress() const { return wallet_switch_dialog_open_.load(); }
wallet::WalletCapabilities walletCapabilities() const { return wallet::currentWalletCapabilities(); }
bool isLiteBuild() const { return wallet::isLiteBuild(walletCapabilities()); }
bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); }
@@ -768,6 +771,14 @@ private:
// sets these and defers to renderSwitchStopDaemonDialog; confirming re-calls switchToWallet(w, true).
bool show_switch_stop_daemon_confirm_ = false;
std::string pending_switch_wallet_file_;
// Live progress for the switch modal: it stays open from confirm through stop → wait-for-exit → start →
// reconnect and auto-closes when the new node connects (onConnected). Phase is worker-updated;
// dialog_open_ gates rendering — both atomic since onConnected/the worker may run off the main thread.
// error_ is written only on the main thread (processWalletSwitchRevert) and read by the (main-thread) UI.
enum class WalletSwitchPhase : int { None = 0, Stopping, Starting, Reconnecting, Failed };
std::atomic<int> wallet_switch_phase_{0};
std::atomic<bool> wallet_switch_dialog_open_{false};
std::string wallet_switch_error_;
// Set by the deleteBlockchainData worker (item count); the main loop surfaces a completion toast
// and resets it to -1. Atomic because the worker thread writes it and the UI thread reads/clears it.

View File

@@ -504,7 +504,13 @@ void App::tryConnect()
void App::onConnected()
{
state_.connected = true;
wallet_switch_pending_confirm_.store(false); // a successful connect confirms the switch
const bool completedSwitch = wallet_switch_pending_confirm_.exchange(false); // this connect confirms a switch
// A successful connect completes a switch — close/clear the progress modal (kept open through the
// stop → start → reconnect sequence, or already hidden via "continue in background").
if (completedSwitch || wallet_switch_dialog_open_.load()) {
wallet_switch_dialog_open_.store(false);
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::None));
}
state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay
daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications
daemon_start_error_shown_ = false;
@@ -1104,9 +1110,11 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
// can't surface (or sign outgoing chat) under the new wallet. It re-provisions from the new
// wallet's seed once it connects. onDisconnected alone doesn't cover chat.
resetChatSession();
ui::Notifications::instance().info(
"Switching wallet — stopping the node and restarting on the new wallet. "
"A graceful shutdown can take up to a minute.", 60.0f);
// Open the switch progress modal — it stays up through stop → wait-for-exit → start → reconnect and
// auto-closes when the new node connects (onConnected). The worker advances the phase from here.
wallet_switch_error_.clear();
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Stopping));
wallet_switch_dialog_open_.store(true);
async_tasks_.submit("Switch wallet", [this, needRescan](const util::AsyncTaskManager::Token&) {
bool ok = false;
@@ -1126,6 +1134,7 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
} else {
// The relaunch is ours — clear the adopted-external latch so the fresh process is treated
// as owned (stop/isRunning/exit behave normally for the next switch and app exit).
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Starting));
if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
if (needRescan && daemon_controller_) daemon_controller_->setRescanOnNextStart(true);
ok = shutting_down_ ? true : startEmbeddedDaemon();
@@ -1135,6 +1144,8 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
if (!isEmbeddedDaemonRunning()) ok = false;
}
// Process is up and staying up — now waiting for RPC to answer (onConnected closes the modal).
if (ok) wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Reconnecting));
}
} catch (...) {
ok = false; // a throw during stop/start is a failed switch — revert rather than wedge
@@ -1167,12 +1178,17 @@ void App::processWalletSwitchRevert()
if (daemon_controller_) daemon_controller_->resetCrashCount();
// Accurate reason: "port wouldn't free" (the running node kept the connection) vs. a genuinely bad
// target wallet. The former is the adopted-external-daemon case; the latter a daemon that exited in init.
if (switch_stop_failed_.exchange(false)) {
ui::Notifications::instance().error(
"Couldn't switch wallets — the running node didn't release its connection in time. "
"It's still on the previous wallet.");
const std::string reason = switch_stop_failed_.exchange(false)
? std::string("Couldn't switch wallets — the running node didn't release its connection in time. "
"It's still on the previous wallet.")
: std::string("Couldn't open that wallet — reverted to the previous one.");
// Surface it in the progress modal if it's still up (keep it open on Failed until the user closes);
// otherwise (the user chose "continue in background") fall back to a toast.
if (wallet_switch_dialog_open_.load()) {
wallet_switch_error_ = reason;
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Failed));
} else {
ui::Notifications::instance().error("Couldn't open that wallet — reverted to the previous one.");
ui::Notifications::instance().error(reason);
}
daemon_restarting_ = false; // the connect loop now restarts the previous wallet's daemon
}

View File

@@ -379,6 +379,11 @@ void App::buildSweepCatalog()
add("modal-switch-stopnode", ui::NavPage::Settings,
[](App& a) { a.pending_switch_wallet_file_ = "wallet-savings.dat"; a.show_switch_stop_daemon_confirm_ = true; },
[](App& a) { a.show_switch_stop_daemon_confirm_ = false; a.pending_switch_wallet_file_.clear(); });
add("modal-switch-progress", ui::NavPage::Settings,
[](App& a) { a.wallet_switch_phase_.store(static_cast<int>(App::WalletSwitchPhase::Stopping));
a.wallet_switch_dialog_open_.store(true); },
[](App& a) { a.wallet_switch_dialog_open_.store(false);
a.wallet_switch_phase_.store(static_cast<int>(App::WalletSwitchPhase::None)); });
// Wave-2 fund/secret dialogs (setup never fires the async RPC — no button is clicked).
add("modal-shield", ui::NavPage::Send,
[](App&) { ui::ShieldDialog::showShieldCoinbase(); },

View File

@@ -1966,6 +1966,7 @@ int main(int argc, char* argv[])
bool backdropNeedsFrames = (backdrop_active || app.getGradientTexture() != 0)
&& !opaqueBackground;
bool animating = app.isShuttingDown()
|| app.isWalletSwitchInProgress()
|| backdropNeedsFrames
|| app.hasTransactionSendProgress()
|| app.isTransactionRefreshInProgress()

View File

@@ -1045,6 +1045,14 @@ void I18n::loadBuiltinEnglish()
strings_["switch_stopnode_warn"] = "A node is already running that this wallet didn't start.";
strings_["switch_stopnode_body"] = "Switching wallets restarts the node on the wallet you selected. The running node will be stopped and relaunched on the new wallet — if you kept it running on purpose, it comes back up automatically.";
strings_["switch_stopnode_confirm"] = "Stop node & switch";
// Wallet-switch live progress modal
strings_["switch_progress_title"] = "Switching wallet";
strings_["switch_progress_failed_title"] = "Wallet switch failed";
strings_["switch_progress_stopping"] = "Stopping the current node";
strings_["switch_progress_starting"] = "Starting the node on the new wallet";
strings_["switch_progress_reconnecting"] = "Reconnecting";
strings_["switch_progress_hint"] = "A graceful shutdown can take up to a minute.";
strings_["switch_progress_background"] = "Continue in background";
// Receive Tab
strings_["receiving_addresses"] = "Your Receiving Addresses";