Files
ObsidianDragon/src/ui/windows/bootstrap_download_dialog.h
DanS 41f5548593 fix(ui): prevent stuck spinners and double-submits in dialogs
Address a cluster of low-severity robustness nits where an async
callback could be skipped (leaving a spinner spinning forever) or a
synchronous action could be re-fired mid-flight:

- app_network: add catch(...) fallbacks around the importPrivateKey and
  submitZSendMany worker lambdas so a non-std throw can't escape the
  worker and skip the main-thread callback (stuck "Importing…"/"Sending…").
- export_transactions: re-entrancy guard disabling Export while a write
  is in flight.
- bootstrap_download: disable Cancel once clicked so the request is
  visibly acknowledged and can't be re-fired before the worker stops.
- network_tab: give clear "already in list" feedback on a duplicate
  server (keep the inputs, clear only the stale invalid-URL error) and
  disable/relabel Refresh while a probe is in flight.
- key_export: offer Retry from the error branch (falls back to Reveal)
  and guard against an empty/malformed address before dispatching.
- receive_tab: only enter the "generating…" state when a dispatch is
  actually possible (guards a stuck spinner when disconnected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 15:08:43 -05:00

320 lines
11 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
#include <string>
#include <memory>
#include "../../app.h"
#include "../../util/bootstrap.h"
#include "../../util/platform.h"
#include "../../util/i18n.h"
#include "../material/draw_helpers.h"
#include "../material/type.h"
#include "../material/colors.h"
#include "../theme.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "imgui.h"
namespace dragonx {
namespace ui {
/**
* @brief Modal dialog for downloading blockchain bootstrap from Settings.
*
* Reuses the existing Bootstrap class for download/verify/extract.
* States: Confirm → Downloading → Done / Failed
*/
class BootstrapDownloadDialog {
public:
static void show(App* app) {
if (!app || !app->supportsFullNodeLifecycleActions()) return;
s_app = app;
// If a download is already in flight, RE-ATTACH to it (just reopen showing progress) instead of
// resetting state and orphaning the running worker / restarting an unverified download.
if (app->isBootstrapDownloading()) {
s_open = true;
if (s_state == State::Confirm) s_state = State::Downloading;
return;
}
s_open = true;
s_state = State::Confirm;
s_bootstrap.reset();
s_errorMsg.clear();
s_wasDaemonRunning = false;
}
static bool isOpen() { return s_open; }
static void render() {
if (!s_app) return;
if (!s_open) {
// Dialog was closed — ensure flag is cleared
if (s_app->isBootstrapDownloading() && s_state != State::Downloading) {
s_app->setBootstrapDownloading(false);
}
return;
}
using namespace material;
const float dp = Layout::dpiScale();
OverlayDialogSpec ov;
ov.title = TR("download_bootstrap"); ov.p_open = &s_open;
ov.style = OverlayStyle::BlurFloat; ov.cardWidth = 500.0f; ov.cardBottomViewportRatio = 0.94f;
if (BeginOverlayDialog(ov)) {
if (s_state == State::Confirm) {
renderConfirm(dp);
} else if (s_state == State::Downloading) {
renderProgress(dp);
} else if (s_state == State::Done) {
renderDone(dp);
} else if (s_state == State::Failed) {
renderFailed(dp);
}
EndOverlayDialog();
}
}
private:
enum class State { Confirm, Downloading, Done, Failed };
// ---- Confirm screen ----
static void renderConfirm(float dp) {
using namespace material;
ImGui::Spacing();
// Description
ImGui::TextWrapped("%s", TR("bootstrap_desc"));
ImGui::Spacing();
// Warning card
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.9f, 0.6f, 0.0f, 0.08f));
ImGui::BeginChild("##bsWarn", ImVec2(0, 0),
ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_AlwaysUseWindowPadding,
ImGuiWindowFlags_NoScrollbar);
{
ImFont* iconFont = Type().iconSmall();
ImGui::PushFont(iconFont);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(Warning()), "%s", ICON_MD_WARNING);
ImGui::PopFont();
ImGui::SameLine();
ImGui::TextWrapped("%s", TR("bootstrap_warning"));
}
ImGui::EndChild();
ImGui::PopStyleColor();
ImGui::Spacing();
// Trust warning
{
ImFont* iconFont = Type().iconSmall();
ImGui::PushFont(iconFont);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()), "%s", ICON_MD_VERIFIED_USER);
ImGui::PopFont();
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()));
ImGui::TextWrapped("%s", TR("bootstrap_trust_warning"));
ImGui::PopStyleColor();
}
ImGui::Spacing();
ImGui::Spacing();
// Buttons: Download | Mirror | Cancel
float btnW = 140.0f * dp;
float btnSm = 90.0f * dp;
if (TactileButton(TR("download"), ImVec2(btnW, 0))) {
startDownload("");
}
ImGui::SameLine();
if (TactileButton(TR("bootstrap_mirror"), ImVec2(btnW, 0))) {
std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
startDownload(mirrorUrl);
}
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", TR("bootstrap_mirror_tooltip"));
}
ImGui::SameLine();
if (TactileButton(TR("cancel"), ImVec2(btnSm, 0))) {
s_open = false;
}
}
// ---- Progress screen ----
static void renderProgress(float dp) {
using namespace material;
if (!s_bootstrap) {
s_state = State::Failed;
s_errorMsg = "Bootstrap not initialized";
return;
}
auto prog = s_bootstrap->getProgress();
// Status title
const char* statusTitle;
if (prog.state == util::Bootstrap::State::Downloading)
statusTitle = TR("bootstrap_downloading");
else if (prog.state == util::Bootstrap::State::Verifying)
statusTitle = TR("bootstrap_verifying");
else
statusTitle = TR("bootstrap_extracting");
Type().text(TypeStyle::Subtitle2, statusTitle);
ImGui::Spacing();
// Progress bar
material::ProgressBar(ImGui::GetContentRegionAvail().x, 8.0f * dp, prog.percent / 100.0f);
ImGui::Spacing();
// Percent + status text
{
char pctBuf[32];
snprintf(pctBuf, sizeof(pctBuf), "%.1f%%", prog.percent);
float pctW = ImGui::CalcTextSize(pctBuf).x;
ImGui::Text("%s", prog.status_text.c_str());
ImGui::SameLine(ImGui::GetContentRegionAvail().x - pctW + ImGui::GetCursorPosX());
ImGui::Text("%s", pctBuf);
}
// wallet.dat protection notice during extraction
if (prog.state == util::Bootstrap::State::Extracting) {
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("bootstrap_wallet_protected"));
}
ImGui::Spacing();
// Daemon status indicator
{
bool daemonUp = s_app->isEmbeddedDaemonRunning();
const std::string& dStatus = s_app->getDaemonStatus();
ImU32 dotCol = daemonUp ? IM_COL32(76, 175, 80, 200)
: IM_COL32(120, 120, 120, 160);
if (dStatus.find("Stopping") != std::string::npos)
dotCol = IM_COL32(255, 167, 38, 200);
const char* label = daemonUp ? (dStatus.find("Stopping") != std::string::npos
? TR("bootstrap_daemon_stopping")
: TR("bootstrap_daemon_running"))
: TR("bootstrap_daemon_stopped");
ImDrawList* ddl = ImGui::GetWindowDrawList();
float dotR = 3.5f * dp;
ImVec2 cp = ImGui::GetCursorScreenPos();
ddl->AddCircleFilled(ImVec2(cp.x + dotR, cp.y + ImGui::GetTextLineHeight() * 0.5f),
dotR, dotCol);
ImGui::Indent(dotR * 2.0f + 6.0f * dp);
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), label);
ImGui::Unindent(dotR * 2.0f + 6.0f * dp);
}
ImGui::Spacing();
ImGui::Spacing();
// Cancel button — once clicked, disable it so the request is visibly
// acknowledged (and can't be re-fired) until the worker actually stops.
float btnW = 100.0f * dp;
ImGui::BeginDisabled(s_cancelling);
if (TactileButton(TR("cancel"), ImVec2(btnW, 0))) {
s_cancelling = true;
s_bootstrap->cancel();
}
ImGui::EndDisabled();
// Check completion
if (s_bootstrap->isDone()) {
auto finalProg = s_bootstrap->getProgress();
if (finalProg.state == util::Bootstrap::State::Completed) {
// Reconcile the preserved wallet.dat against the new chain once the daemon is back up.
s_app->markPostBootstrapRescanPending();
s_state = State::Done;
} else {
s_errorMsg = finalProg.error;
if (s_errorMsg.empty()) s_errorMsg = "Bootstrap failed";
s_state = State::Failed;
}
s_bootstrap.reset();
s_cancelling = false;
}
}
// ---- Done screen ----
static void renderDone(float dp) {
using namespace material;
ImGui::Spacing();
Type().textColored(TypeStyle::H6, Success(), TR("bootstrap_success"));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("bootstrap_success_desc"));
ImGui::Spacing();
ImGui::Spacing();
float btnW = 140.0f * dp;
if (s_wasDaemonRunning) {
if (TactileButton(TR("bootstrap_restart_daemon"), ImVec2(btnW, 0))) {
s_app->setBootstrapDownloading(false);
s_app->startEmbeddedDaemon();
s_open = false;
}
ImGui::SameLine();
}
if (TactileButton(TR("close"), ImVec2(90.0f * dp, 0))) {
s_app->setBootstrapDownloading(false);
s_open = false;
}
}
// ---- Failed screen ----
static void renderFailed(float dp) {
using namespace material;
ImGui::Spacing();
Type().textColored(TypeStyle::H6, Error(), TR("bootstrap_failed"));
ImGui::Spacing();
ImGui::TextWrapped("%s", s_errorMsg.c_str());
ImGui::Spacing();
ImGui::Spacing();
float btnW = 120.0f * dp;
if (TactileButton(TR("retry"), ImVec2(btnW, 0))) {
startDownload("");
}
ImGui::SameLine();
if (TactileButton(TR("close"), ImVec2(90.0f * dp, 0))) {
s_app->setBootstrapDownloading(false);
s_open = false;
}
}
// ---- Shared: kick off download ----
static void startDownload(const std::string& url) {
if (!s_app || !s_app->supportsFullNodeLifecycleActions()) return;
s_wasDaemonRunning = s_app->stopDaemonForBootstrap();
s_app->setBootstrapDownloading(true);
s_bootstrap = std::make_unique<util::Bootstrap>();
std::string dataDir = util::Platform::getDragonXDataDir();
if (url.empty())
s_bootstrap->start(dataDir);
else
s_bootstrap->start(dataDir, url);
s_state = State::Downloading;
s_errorMsg.clear();
s_cancelling = false;
}
static inline bool s_open = false;
static inline App* s_app = nullptr;
static inline State s_state = State::Confirm;
static inline std::unique_ptr<util::Bootstrap> s_bootstrap;
static inline bool s_wasDaemonRunning = false;
static inline bool s_cancelling = false; // cancel requested; disables the Cancel button until the worker stops
static inline std::string s_errorMsg;
};
} // namespace ui
} // namespace dragonx