Files
ObsidianDragon/src/ui/windows/bootstrap_download_dialog.h
T
DanSandClaude Opus 4.8 c3ffc97b31 fix(ui): finish a stranded bootstrap even when its dialog is closed
A Settings bootstrap was only wrapped up inside the dialog's own draw
path. If the dialog finished downloading, verifying or extracting while
the user had clicked away or minimized the window, completion was never
processed: the connect and reconcile gate stayed held and the
post-bootstrap rescan was never armed.

- Completion is now a pure decision driven from a pump() that both the
  per-frame update and the dialog's render call, so a finished bootstrap
  is handled every tick whether or not the dialog is drawn.
- With the dialog open the behaviour is unchanged: it advances to the
  Done or Failed screen and those screens still own the gate. With the
  dialog closed, pump() releases the gate itself and shows a success or
  failure toast; a user-cancelled run is released silently.

User-visible: a bootstrap started from Settings now completes, arms its
rescan and reports its result even if the dialog is closed or the window
is minimized when it finishes.

Tests: the completion decision for an unfinished worker, a completed run
with the dialog open and closed, a failed run, and a user-cancelled run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00

355 lines
14 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
#include <chrono>
#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 "../notifications.h"
#include "bootstrap_completion.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; }
// Close the dialog (used by the UI sweep teardown).
static void hide() { s_open = false; s_state = State::Confirm; }
// Quit, from beginShutdown(): cancel a running Settings bootstrap without waiting for it.
static void cancelForShutdown() {
if (s_bootstrap) s_bootstrap->cancel();
}
// Quit: true while a cancelled Settings bootstrap is still replacing the chain data. Its abort removes the
// partial chain; the shutdown screen stays up until that is done, so main()'s exit watchdog never cuts it.
static bool quitPending() { return s_bootstrap && !s_bootstrap->safeToAbandon(); }
// Quit, from App::shutdown(): cancel a running Settings bootstrap and wait for its worker until `deadline`
// (main() ends in _Exit, and its 15 s watchdog is already running). A worker still running then is left to
// the process exit and never destroyed (~Bootstrap would join).
static void abortForShutdown(std::chrono::steady_clock::time_point deadline) {
if (!s_bootstrap) return;
if (s_bootstrap->cancelAndWait(deadline)) s_bootstrap.reset();
else (void)s_bootstrap.release();
}
// Process a finished worker. Idempotent and safe every tick: App::update() calls it so completion never
// depends on the dialog being drawn (clicked away, window minimized); renderProgress() calls it too (the
// UI sweep returns from update() early). With the dialog open it only advances the screen (Done/Failed own
// the gate: Close / Restart daemon clear it, so the Done screen behaves as before). With the dialog
// closed nobody can press those, so release the connect/reconcile gate here and toast the outcome.
static void pump() {
if (!s_app || !s_bootstrap || s_app->isShuttingDown()) return; // quit: abortForShutdown() owns it
if (!s_bootstrap->isDone()) return;
const auto finalProg = s_bootstrap->getProgress();
const auto c = resolveBootstrapCompletion(true, finalProg.state, s_open, s_cancelling);
if (c.markRescan) s_app->markPostBootstrapRescanPending();
if (c.next == BootstrapCompletion::Next::Done) {
s_state = State::Done;
} else {
s_errorMsg = finalProg.error;
if (s_errorMsg.empty()) s_errorMsg = TR("grpc_bootstrap_failed");
s_state = State::Failed;
}
s_bootstrap.reset();
s_cancelling = false;
if (c.releaseGate) s_app->setBootstrapDownloading(false);
if (c.toast) {
if (c.next == BootstrapCompletion::Next::Done)
Notifications::instance().success(TR("bootstrap_success"));
else
Notifications::instance().error(std::string(TR("bootstrap_failed")) + ": " + s_errorMsg);
}
}
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();
// The download runs on its own thread: keep frames coming for the progress bar.
if (s_state == State::Downloading) RequestProgressFrame();
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();
// Care header (reference style: ⚠ + Warning color, wraps if long).
DialogWarningHeader(TR("bootstrap_warning"));
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 — centered as a group.
float btnW = 140.0f * dp;
float btnSm = 90.0f * dp;
const float rowTotal = btnW * 2.0f + btnSm + ImGui::GetStyle().ItemSpacing.x * 2.0f;
const float rowOff = (ImGui::GetContentRegionAvail().x - rowTotal) * 0.5f;
if (rowOff > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + rowOff);
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 = TR("grpc_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. It is also disabled while
// the old chain data is being replaced (cancelling then would only leave no chain at all).
float btnW = 100.0f * dp;
ImGui::BeginDisabled(s_cancelling || !prog.cancellable);
if (TactileButton(TR("cancel"), ImVec2(btnW, 0))) {
// Refused (no-op) if the destructive window started after this frame read prog.cancellable.
s_cancelling = s_bootstrap->requestCancel();
}
ImGui::EndDisabled();
// Check completion (shared with App::update() so a closed dialog can't strand a finished bootstrap)
pump();
}
// ---- 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;
// It stops the node first: not while it is busy or running a key import / rescan (as the wizard's bootstrap).
if (s_app->refuseIfNodeBusy()) 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