- Bootstrap: Retry now reuses the source the user actually started (was always the default), and a "Try mirror" button offers the alternate server on failure (s_lastUrl remembered on start, cleared on a fresh Confirm). - Validate Address: classification + checksum are local, so the dialog no longer requires a node connection — offline/lite now validate and show the type, with ownership reported as "Unknown — node offline / not checkable in the lite wallet" instead of a dead "not connected" screen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
364 lines
14 KiB
C++
364 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_lastUrl.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(s_lastUrl); // keep the user's source/mirror choice (was startDownload(""))
|
|
}
|
|
ImGui::SameLine();
|
|
{
|
|
const std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
|
|
if (s_lastUrl != mirrorUrl && TactileButton(TR("bootstrap_try_mirror"), ImVec2(130.0f * dp, 0)))
|
|
startDownload(mirrorUrl);
|
|
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_lastUrl = url; // remember what we started (set only after the guards passed)
|
|
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_lastUrl; // last source started ("" = default); so Retry keeps the mirror choice
|
|
static inline std::string s_errorMsg;
|
|
};
|
|
|
|
} // namespace ui
|
|
} // namespace dragonx
|