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

@@ -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
}