The Core poll ran z_gettotalbalance twice (minconf 0 and 1), each a whole- wallet scan under dragonxd's cs_main. The spendable (minconf=1) call now runs only after the display call succeeded, and only when something is unconfirmed or pending or the exact total moved: about 95% of polls make one scan instead of two. A forced post-send balance can no longer be lost to a refused enqueue, and pending-send debits aren't re-applied each tick. z_getoperationstatus is filtered to our opids with its real argument form ([[id,...]]; the old ["id"] was rejected), falling back to the unfiltered call if a daemon refuses the filter. The Shield/Merge dialog's own polls are filtered and session-guarded too, and finally reach Done; the dead RPCClient wrappers are deleted. The Merge/Consolidate scope scan becomes a tested state machine: one scan at a time, failures latch behind a Retry button, only "node not ready" retries by itself (10/30/60 s, at most 3), Lite never scans. Its 120 s calls are maintenance calls: every main-thread disconnect aborts one in flight first, so a lifecycle action no longer freezes the UI behind it. An aborted merge that was already sent says it may still go through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
879 lines
46 KiB
C++
879 lines
46 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#include "shield_dialog.h"
|
|
#include "../../app.h"
|
|
#include "../../config/version.h"
|
|
#include "../../data/wallet_state.h"
|
|
#include "../../rpc/rpc_client.h"
|
|
#include "../../rpc/rpc_worker.h"
|
|
#include "../../services/note_consolidation.h"
|
|
#include "../../util/i18n.h"
|
|
#include "../notifications.h"
|
|
#include "../schema/ui_schema.h"
|
|
#include "../material/draw_helpers.h"
|
|
#include "imgui.h"
|
|
|
|
#include <algorithm>
|
|
#include <cstdint>
|
|
#include <set>
|
|
#include <stdexcept>
|
|
#include <vector>
|
|
#include <string>
|
|
#include <cstdio>
|
|
|
|
namespace dragonx {
|
|
namespace ui {
|
|
|
|
// ── Static dialog state ─────────────────────────────────────────────────────────────────────────
|
|
static bool s_open = false;
|
|
static ShieldDialog::Mode s_mode = ShieldDialog::Mode::ShieldCoinbase;
|
|
static bool s_consolidate = false; // opened from the wallet-bloat nudge (shielded preset + framing)
|
|
static int s_src = 2; // merge source: 0 = transparent, 1 = shielded, 2 = both
|
|
static char s_from_address[512] = "*";
|
|
static char s_to_address[512] = "";
|
|
static int s_selected_zaddr_idx = -1;
|
|
static double s_fee = DRAGONX_DEFAULT_FEE;
|
|
static int s_utxo_limit = 50; // overridden by schema at runtime
|
|
static bool s_advanced = false; // Advanced (fee + batch size) disclosure
|
|
static bool s_confirm = false; // inline "confirm before moving funds" phase
|
|
static bool s_operation_pending = false;
|
|
static bool s_op_terminal = false; // async op reached success/failed — freeze inputs
|
|
static std::string s_operation_id;
|
|
static std::string s_status_message;
|
|
static double s_last_poll = 0.0; // live-progress self-poll timer (ImGui::GetTime seconds)
|
|
// Scope of what can be consolidated (merge mode only). services::ScopeScan decides when to scan.
|
|
static services::ScopeScan s_scan;
|
|
static int s_t_count = 0, s_z_count = 0;
|
|
static double s_t_amount = 0.0, s_z_amount = 0.0;
|
|
static bool s_creating_addr = false; // z_getnewaddress in flight (empty state)
|
|
// "Keep each address's funds in that address": every ticked address's notes merge back into it, so
|
|
// separately labelled funds stay apart. Shielded only (a transparent address can't take change).
|
|
static bool s_per_address = false;
|
|
static std::vector<services::NoteGroup> s_groups; // spendable shielded notes by address (scope load)
|
|
static std::set<std::string> s_selected; // addresses ticked for per-address consolidation
|
|
static std::set<std::string> s_unticked; // unticked by the user this opening: kept across a re-scan
|
|
struct PerAddressRun {
|
|
services::AddressRun run;
|
|
std::vector<int> state; // per opid: 0 = running, 1 = done, 2 = failed, 3 = gone from the daemon (unknown)
|
|
std::string failure; // first failed op's message
|
|
};
|
|
static std::vector<PerAddressRun> s_runs; // after submit
|
|
static bool s_runs_submitting = false;
|
|
// The op-status polls: one in flight at a time, cleared only by its reply, even across a close and
|
|
// reopen; a reply for an earlier opening (another session) changes nothing else.
|
|
static bool s_poll_in_flight = false;
|
|
static unsigned s_poll_session = 0;
|
|
|
|
static void resetTransient()
|
|
{
|
|
++s_poll_session;
|
|
s_operation_pending = false;
|
|
s_op_terminal = false;
|
|
s_confirm = false;
|
|
s_status_message.clear();
|
|
s_operation_id.clear();
|
|
s_creating_addr = false;
|
|
s_runs.clear();
|
|
s_runs_submitting = false;
|
|
}
|
|
|
|
// Count + sum spendable transparent UTXOs and shielded notes so the user can see the scope of a
|
|
// consolidation (and how many batches it may take). Read-only; runs off the UI thread. Only when
|
|
// services::scopeScanWant() says so: both calls are whole-wallet scans under the daemon's cs_main.
|
|
static void loadScope(App* app)
|
|
{
|
|
if (!app || !app->worker()) return;
|
|
const std::uint32_t gen = services::scopeScanBegin(s_scan, services::steadySeconds());
|
|
app->worker()->post([app, rpc = app->rpc(), gen]() -> rpc::RPCWorker::MainCb {
|
|
int tC = 0, zC = 0; double tA = 0.0, zA = 0.0; std::string error;
|
|
rpc::RpcFailureKind kind = rpc::RpcFailureKind::None;
|
|
std::vector<services::NoteGroup> groups;
|
|
try {
|
|
if (!rpc) throw std::runtime_error("Not connected");
|
|
// On a large wallet each scan can outlast the default 30 s; give them 120 s, counted as
|
|
// maintenance calls so a disconnect aborts them rather than waiting.
|
|
App::MaintenanceCallScope maint(*app);
|
|
rpc::RPCClient::TraceScope trace("Shield dialog / Scope count");
|
|
nlohmann::json us = rpc->call("listunspent", nlohmann::json::array({0}), services::kScopeCallTimeoutSec);
|
|
if (us.is_array()) for (const auto& u : us) {
|
|
if (u.value("confirmations", 0) >= 1 && u.value("spendable", true)) { ++tC; tA += u.value("amount", 0.0); }
|
|
}
|
|
nlohmann::json zs = rpc->call("z_listunspent", nlohmann::json::array({0}), services::kScopeCallTimeoutSec);
|
|
if (zs.is_array()) for (const auto& z : zs) {
|
|
if (z.value("confirmations", 0) >= 1 && !services::noteIsLocked(z)) { ++zC; zA += z.value("amount", 0.0); }
|
|
}
|
|
groups = services::groupSpendableNotes(zs);
|
|
} catch (const std::exception& e) {
|
|
error = e.what();
|
|
kind = rpc::rpcFailureKindOf(e);
|
|
if (error.empty()) error = "error";
|
|
} catch (...) {
|
|
error = "unknown error";
|
|
kind = rpc::RpcFailureKind::Other;
|
|
}
|
|
return [tC, zC, tA, zA, error, kind, gen, groups]() {
|
|
if (!services::scopeScanFinish(s_scan, gen, error, kind, services::steadySeconds())) return;
|
|
s_t_count = tC; s_z_count = zC; s_t_amount = tA; s_z_amount = zA;
|
|
s_groups = groups;
|
|
s_selected.clear(); // every address worth it, ticked by default, except ones the user unticked
|
|
for (const auto& g : s_groups)
|
|
if (services::worthConsolidating(g, s_fee) && !s_unticked.count(g.address))
|
|
s_selected.insert(g.address);
|
|
// Clamp the source to what actually has inputs (unless the user is mid-op).
|
|
const bool tOk = tC > 0, zOk = zC > 0;
|
|
if (!s_operation_pending) {
|
|
if (s_consolidate && zOk) s_src = 1; // bloat nudge → shielded
|
|
else if (s_src == 0 && !tOk) s_src = zOk ? 1 : 2;
|
|
else if (s_src == 1 && !zOk) s_src = tOk ? 0 : 2;
|
|
else if (!tOk && zOk) s_src = 1;
|
|
else if (tOk && !zOk) s_src = 0;
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
void ShieldDialog::show(Mode mode)
|
|
{
|
|
s_mode = mode;
|
|
s_open = true;
|
|
s_consolidate = false; // reset preset flags so stale statics don't leak across opens
|
|
s_src = 2;
|
|
resetTransient();
|
|
s_from_address[0] = '\0';
|
|
if (mode == Mode::ShieldCoinbase) strncpy(s_from_address, "*", sizeof(s_from_address));
|
|
s_to_address[0] = '\0';
|
|
s_selected_zaddr_idx = -1;
|
|
s_fee = DRAGONX_DEFAULT_FEE;
|
|
s_utxo_limit = (int)schema::UI().drawElement("business", "utxo-limit").size;
|
|
if (s_utxo_limit < 1) s_utxo_limit = 50;
|
|
s_advanced = false;
|
|
services::scopeScanOpen(s_scan, services::steadySeconds());
|
|
s_t_count = s_z_count = 0; s_t_amount = s_z_amount = 0.0;
|
|
s_last_poll = 0.0;
|
|
s_per_address = false;
|
|
s_groups.clear();
|
|
s_selected.clear();
|
|
s_unticked.clear();
|
|
}
|
|
|
|
void ShieldDialog::showShieldCoinbase(const std::string& fromAddress)
|
|
{
|
|
show(Mode::ShieldCoinbase);
|
|
strncpy(s_from_address, fromAddress.c_str(), sizeof(s_from_address) - 1);
|
|
}
|
|
|
|
void ShieldDialog::showMerge()
|
|
{
|
|
show(Mode::MergeToAddress);
|
|
s_consolidate = false;
|
|
s_src = 2; // generic merge: both sources
|
|
}
|
|
|
|
void ShieldDialog::showConsolidate()
|
|
{
|
|
show(Mode::MergeToAddress);
|
|
s_consolidate = true;
|
|
s_src = 1; // wallet-bloat consolidation targets shielded notes (witness bloat)
|
|
s_per_address = true; // shed notes without moving funds between (labelled) addresses
|
|
}
|
|
|
|
void ShieldDialog::hide()
|
|
{
|
|
s_open = false;
|
|
resetTransient();
|
|
}
|
|
|
|
void ShieldDialog::invalidateScope()
|
|
{
|
|
s_confirm = false; // what the user was confirming was read on the old connection
|
|
services::scopeScanInvalidate(s_scan);
|
|
s_t_count = s_z_count = 0; s_t_amount = s_z_amount = 0.0;
|
|
s_groups.clear();
|
|
s_selected.clear();
|
|
}
|
|
|
|
// Relevant count/amount for the currently-selected merge source.
|
|
static int srcCount() { return s_src == 0 ? s_t_count : s_src == 1 ? s_z_count : (s_t_count + s_z_count); }
|
|
static double srcAmount() { return s_src == 0 ? s_t_amount : s_src == 1 ? s_z_amount : (s_t_amount + s_z_amount); }
|
|
|
|
static std::string fmtAmt(double v) { char b[48]; std::snprintf(b, sizeof(b), "%.4f", v); return b; }
|
|
|
|
// A caption-style line that wraps at the dialog edge (Type().textColored doesn't wrap).
|
|
static void wrappedCaption(ImU32 color, const char* text)
|
|
{
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color));
|
|
material::Type().textWrapped(material::TypeStyle::Caption, text);
|
|
ImGui::PopStyleColor();
|
|
}
|
|
|
|
static std::string shortAddr(const std::string& a)
|
|
{
|
|
if (a.size() <= 20) return a;
|
|
return a.substr(0, 10) + "…" + a.substr(a.size() - 8);
|
|
}
|
|
|
|
// Auto-pick the best spendable z-address as the default destination (fewest hops for the user).
|
|
static void autoSelectDestination(const WalletState& state)
|
|
{
|
|
if (s_to_address[0] != '\0' || state.z_addresses.empty()) return;
|
|
int idx = bestSpendableAddressIndex(state.z_addresses);
|
|
if (idx < 0) idx = 0;
|
|
s_selected_zaddr_idx = idx;
|
|
strncpy(s_to_address, state.z_addresses[idx].address.c_str(), sizeof(s_to_address) - 1);
|
|
}
|
|
|
|
// Fire the actual shield/merge op. Registers the opid with the shared poller (for balance refresh)
|
|
// AND kicks the modal's own live-progress poll.
|
|
static void submitOperation(App* app)
|
|
{
|
|
s_operation_pending = true;
|
|
s_op_terminal = false;
|
|
s_status_message = TR("shield_submitting");
|
|
s_last_poll = ImGui::GetTime();
|
|
|
|
if (s_mode == ShieldDialog::Mode::ShieldCoinbase) {
|
|
std::string from(s_from_address), to(s_to_address);
|
|
double fee = s_fee; int limit = s_utxo_limit;
|
|
if (!app->worker()) return;
|
|
app->worker()->post([app, rpc = app->rpc(), from, to, fee, limit]() -> rpc::RPCWorker::MainCb {
|
|
nlohmann::json result; std::string error;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Shield dialog / Shield coinbase");
|
|
result = rpc->call("z_shieldcoinbase", {from, to, fee, limit});
|
|
} catch (const std::exception& e) { error = e.what(); }
|
|
return [app, result, error]() {
|
|
if (error.empty()) {
|
|
s_operation_id = result.value("opid", "");
|
|
s_status_message = TR("merge_progress");
|
|
Notifications::instance().success(TR("shield_started"));
|
|
app->trackOperation(s_operation_id);
|
|
} else {
|
|
s_operation_pending = false; s_op_terminal = true;
|
|
s_status_message = std::string(TR("shield_error_prefix")) + error;
|
|
Notifications::instance().error(std::string(TR("shield_send_failed")) + error);
|
|
}
|
|
};
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Merge / consolidate. Source → z_mergetoaddress fromaddress selector (this is the fix: shielded
|
|
// notes, not just transparent UTXOs — the wallet-bloat the nudge warns about is shielded witnesses).
|
|
std::vector<std::string> fromAddrs;
|
|
if (s_src == 0) fromAddrs = { "ANY_TADDR" };
|
|
else if (s_src == 1) fromAddrs = { "ANY_SAPLING" };
|
|
else fromAddrs = { "*" };
|
|
std::string to(s_to_address);
|
|
double fee = s_fee; int limit = s_utxo_limit;
|
|
if (!app->worker()) return;
|
|
app->worker()->post([app, rpc = app->rpc(), fromAddrs, to, fee, limit]() -> rpc::RPCWorker::MainCb {
|
|
nlohmann::json addrs = nlohmann::json::array();
|
|
for (const auto& a : fromAddrs) addrs.push_back(a);
|
|
nlohmann::json result; std::string error;
|
|
bool uncertain = false;
|
|
try {
|
|
if (!rpc) throw std::runtime_error("Not connected");
|
|
// The daemon scans the whole wallet before it returns the opid; on a large wallet that can
|
|
// outlast 30 s, which reported a failure for a merge that had started. 120 s, as a maintenance
|
|
// call so a disconnect aborts it instead of waiting.
|
|
App::MaintenanceCallScope maint(*app);
|
|
rpc::RPCClient::TraceScope trace("Shield dialog / Consolidate");
|
|
// fromaddrs, toaddr, fee, transparent_limit, shielded_limit — cap both to the batch size.
|
|
result = rpc->call("z_mergetoaddress", {addrs, to, fee, limit, limit}, services::kScopeCallTimeoutSec);
|
|
} catch (const std::exception& e) {
|
|
error = e.what();
|
|
// Sent, then cut off with no reply: our own abort on a lifecycle disconnect, the reply timeout
|
|
// or a dropped connection. The daemon keeps running the handler and may still queue the merge.
|
|
if (const auto* t = dynamic_cast<const rpc::RpcTransportError*>(&e))
|
|
uncertain = t->requestSent && t->httpStatus == 0;
|
|
}
|
|
return [app, result, error, uncertain]() {
|
|
if (error.empty()) {
|
|
s_operation_id = result.value("opid", "");
|
|
s_status_message = TR("merge_progress");
|
|
Notifications::instance().success(TR("merge_started"));
|
|
app->trackOperation(s_operation_id);
|
|
} else if (uncertain) {
|
|
s_operation_pending = false; s_op_terminal = true;
|
|
s_confirm = false; // don't leave Confirm armed for a merge that may still be running
|
|
s_status_message = TR("merge_submit_uncertain");
|
|
Notifications::instance().warning(TR("merge_submit_uncertain"));
|
|
} else {
|
|
s_operation_pending = false; s_op_terminal = true;
|
|
s_status_message = std::string(TR("shield_error_prefix")) + error;
|
|
Notifications::instance().error(std::string(TR("merge_send_failed")) + error);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
// The ticked addresses that are worth consolidating, in list order.
|
|
static std::vector<services::NoteGroup> selectedGroups()
|
|
{
|
|
std::vector<services::NoteGroup> out;
|
|
for (const auto& g : s_groups)
|
|
if (s_selected.count(g.address) && services::worthConsolidating(g, s_fee)) out.push_back(g);
|
|
return out;
|
|
}
|
|
|
|
// Per-address consolidation. One background job submits, address by address, merges of each address's
|
|
// notes back into that address — several batches when it holds more notes than the batch size.
|
|
// z_mergetoaddress locks a batch's notes before it returns, so back-to-back calls take disjoint notes.
|
|
static void submitPerAddress(App* app)
|
|
{
|
|
const auto groups = selectedGroups();
|
|
if (groups.empty() || !app->worker()) return;
|
|
s_runs_submitting = true;
|
|
s_operation_pending = true;
|
|
s_op_terminal = false;
|
|
s_status_message = TR("shield_submitting");
|
|
s_last_poll = ImGui::GetTime();
|
|
const double fee = s_fee;
|
|
const int limit = s_utxo_limit;
|
|
app->worker()->post([app, rpc = app->rpc(), groups, fee, limit]() -> rpc::RPCWorker::MainCb {
|
|
std::vector<services::AddressRun> runs;
|
|
for (const auto& g : groups) {
|
|
runs.push_back(services::consolidateInPlace(g.address, [&](const std::string& addr) {
|
|
services::MergeCallResult r;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Shield dialog / Consolidate in place");
|
|
// fromaddrs = [addr], toaddr = addr: the address's notes merge back into it.
|
|
const nlohmann::json res = rpc->call("z_mergetoaddress",
|
|
{nlohmann::json::array({addr}), addr, fee, limit, limit});
|
|
r.opid = res.value("opid", std::string());
|
|
r.merging = res.value("mergingNotes", 0);
|
|
r.remaining = res.value("remainingNotes", 0);
|
|
if (r.opid.empty()) r.error = "no operation id";
|
|
} catch (const std::exception& e) { r.error = e.what(); }
|
|
return r;
|
|
}));
|
|
}
|
|
return [app, runs]() {
|
|
s_runs_submitting = false;
|
|
s_runs.clear();
|
|
int submitted = 0;
|
|
for (const auto& r : runs) {
|
|
PerAddressRun pr;
|
|
pr.run = r;
|
|
pr.state.assign(r.opids.size(), 0);
|
|
for (const auto& id : r.opids) { app->trackOperation(id); ++submitted; }
|
|
s_runs.push_back(std::move(pr));
|
|
}
|
|
if (submitted > 0) {
|
|
s_status_message = TR("merge_progress");
|
|
Notifications::instance().success(TR("merge_started"));
|
|
} else {
|
|
s_operation_pending = false; s_op_terminal = true;
|
|
s_status_message = TR("merge_keep_each_nothing_submitted");
|
|
Notifications::instance().error(TR("merge_keep_each_nothing_submitted"));
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
// Progress of a per-address run: poll every ~2s until each batch's operation is done or failed. Filtered
|
|
// to the batches still running, as the shared poller does (unfiltered if the daemon rejected the filter).
|
|
static void pollPerAddress(App* app)
|
|
{
|
|
if (s_runs.empty() || s_op_terminal || s_poll_in_flight || !app->worker()) return;
|
|
const double now = ImGui::GetTime();
|
|
if (now - s_last_poll < 2.0) return;
|
|
std::vector<std::string> running;
|
|
for (const auto& r : s_runs)
|
|
for (size_t i = 0; i < r.run.opids.size(); ++i)
|
|
if (r.state[i] == 0) running.push_back(r.run.opids[i]);
|
|
const auto filter = services::NetworkRefreshService::buildOperationFilterParams(running);
|
|
if (!filter) return;
|
|
s_last_poll = now;
|
|
s_poll_in_flight = true;
|
|
const unsigned session = s_poll_session;
|
|
nlohmann::json params = app->opidFilterUnsupported() ? nlohmann::json::array() : *filter;
|
|
app->worker()->post([rpc = app->rpc(), params = std::move(params), session]() -> rpc::RPCWorker::MainCb {
|
|
nlohmann::json result; std::string error;
|
|
try {
|
|
if (!rpc) throw std::runtime_error("Not connected");
|
|
rpc::RPCClient::TraceScope trace("Shield dialog / Consolidate in place status");
|
|
result = rpc->call("z_getoperationstatus", params);
|
|
} catch (const std::exception& e) { error = e.what(); }
|
|
return [result, error, session]() {
|
|
s_poll_in_flight = false;
|
|
if (session != s_poll_session) return; // a reply for an earlier opening
|
|
if (!error.empty() || !result.is_array()) return; // transient — retry next tick
|
|
std::set<std::string> reported;
|
|
for (const auto& op : result) {
|
|
if (!op.is_object() || !op.contains("id") || !op["id"].is_string()) continue;
|
|
const std::string id = op["id"].get<std::string>();
|
|
reported.insert(id);
|
|
const std::string status = (op.contains("status") && op["status"].is_string())
|
|
? op["status"].get<std::string>() : std::string();
|
|
for (auto& r : s_runs) {
|
|
for (size_t i = 0; i < r.run.opids.size(); ++i) {
|
|
if (r.run.opids[i] != id || r.state[i] != 0) continue;
|
|
if (status == "success") r.state[i] = 1;
|
|
else if (status == "failed" || status == "cancelled" || status == "canceled") {
|
|
r.state[i] = 2;
|
|
if (r.failure.empty() && op.contains("error") && op["error"].is_object() &&
|
|
op["error"].contains("message") && op["error"]["message"].is_string())
|
|
r.failure = op["error"]["message"].get<std::string>();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// A valid reply without one of our running ops: the daemon no longer holds it (restart, or
|
|
// popped by z_getoperationresult); its outcome can't be confirmed. One poll at a time, and
|
|
// states change only here, so the running set now is the set that was asked for.
|
|
for (auto& r : s_runs)
|
|
for (size_t i = 0; i < r.run.opids.size(); ++i)
|
|
if (r.state[i] == 0 && !reported.count(r.run.opids[i])) r.state[i] = 3;
|
|
bool allDone = true;
|
|
int failed = 0, unknown = 0;
|
|
for (const auto& r : s_runs)
|
|
for (int st : r.state) { if (st == 0) allDone = false; if (st == 2) ++failed; if (st == 3) ++unknown; }
|
|
if (!allDone) return;
|
|
s_operation_pending = false;
|
|
s_op_terminal = true;
|
|
if (failed > 0) {
|
|
s_status_message = TR("merge_keep_each_some_failed");
|
|
Notifications::instance().error(TR("merge_keep_each_some_failed"));
|
|
} else if (unknown > 0) {
|
|
s_status_message = TR("send_status_unconfirmed");
|
|
Notifications::instance().warning(TR("send_status_unconfirmed"));
|
|
} else {
|
|
s_status_message = TR("shield_completed");
|
|
Notifications::instance().success(TR("shield_merge_done"));
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
// Live-progress self-poll: while an op is in flight, poll z_getoperationstatus every ~2s so the modal
|
|
// shows "Consolidating… → Done/Failed" without a manual button. (The shared poller also tracks it: it
|
|
// refreshes balances and raises the failure toast; this drives the inline display and the success toast.)
|
|
static void pollOperation(App* app)
|
|
{
|
|
if (s_operation_id.empty() || s_op_terminal || s_poll_in_flight || !app->worker()) return;
|
|
const double now = ImGui::GetTime();
|
|
if (now - s_last_poll < 2.0) return;
|
|
const std::string opid = s_operation_id;
|
|
// [[opid]]: the argument is an ARRAY of ids. The old {json::array({opid})} collapsed to ["opid"]
|
|
// (C++17 brace init of a json from one json copies it), which the daemon rejects, so this poll failed
|
|
// every time and the dialog never saw its op finish.
|
|
const auto filter = services::NetworkRefreshService::buildOperationFilterParams({opid});
|
|
if (!filter) return;
|
|
s_last_poll = now;
|
|
s_poll_in_flight = true;
|
|
const unsigned session = s_poll_session;
|
|
nlohmann::json params = app->opidFilterUnsupported() ? nlohmann::json::array() : *filter;
|
|
app->worker()->post([rpc = app->rpc(), params = std::move(params), opid, session]() -> rpc::RPCWorker::MainCb {
|
|
nlohmann::json result; std::string error;
|
|
try {
|
|
if (!rpc) throw std::runtime_error("Not connected");
|
|
rpc::RPCClient::TraceScope trace("Shield dialog / Op status");
|
|
result = rpc->call("z_getoperationstatus", params);
|
|
} catch (const std::exception& e) { error = e.what(); }
|
|
return [result, error, opid, session]() {
|
|
s_poll_in_flight = false;
|
|
if (session != s_poll_session || opid != s_operation_id) return; // the dialog moved on
|
|
if (!error.empty() || !result.is_array()) return; // transient — retry next tick
|
|
const nlohmann::json* found = nullptr;
|
|
for (const auto& o : result) {
|
|
if (o.is_object() && o.contains("id") && o["id"].is_string() && o["id"].get<std::string>() == opid) {
|
|
found = &o;
|
|
break;
|
|
}
|
|
}
|
|
if (!found) {
|
|
// A valid reply without our op: the daemon no longer holds it (it restarted, or another
|
|
// client popped it with z_getoperationresult). It registers an op before returning its id,
|
|
// so this is no race. The outcome can't be confirmed; stop polling.
|
|
s_operation_pending = false; s_op_terminal = true;
|
|
s_status_message = TR("send_status_unconfirmed");
|
|
Notifications::instance().warning(TR("send_status_unconfirmed"));
|
|
return;
|
|
}
|
|
const nlohmann::json& op = *found;
|
|
const std::string status = (op.contains("status") && op["status"].is_string())
|
|
? op["status"].get<std::string>() : std::string();
|
|
if (status == "success") {
|
|
s_operation_pending = false; s_op_terminal = true;
|
|
s_status_message = TR("shield_completed");
|
|
Notifications::instance().success(TR("shield_merge_done"));
|
|
} else if (status == "failed" || status == "cancelled" || status == "canceled") {
|
|
std::string msg = TR("shield_unknown_error");
|
|
if (op.contains("error") && op["error"].is_object() && op["error"].contains("message") &&
|
|
op["error"]["message"].is_string())
|
|
msg = op["error"]["message"].get<std::string>();
|
|
s_operation_pending = false; s_op_terminal = true;
|
|
s_status_message = std::string(TR("shield_op_failed")) + msg;
|
|
// No toast: the op is tracked (App::trackOperation) and the shared opid poller raises the
|
|
// one failure toast, whether this dialog is open or not.
|
|
}
|
|
// queued / executing → leave the "Consolidating…" message and keep polling.
|
|
};
|
|
});
|
|
}
|
|
|
|
void ShieldDialog::render(App* app)
|
|
{
|
|
if (!s_open) return;
|
|
|
|
auto& S = schema::UI();
|
|
auto win = S.window("dialogs.shield");
|
|
auto addrLbl = S.label("dialogs.shield", "address-label");
|
|
auto addrFront = S.label("dialogs.shield", "address-front-label");
|
|
auto addrBack = S.label("dialogs.shield", "address-back-label");
|
|
auto feeInput = S.input("dialogs.shield", "fee-input");
|
|
auto utxoInput = S.input("dialogs.shield", "utxo-limit-input");
|
|
auto shieldBtn = S.button("dialogs.shield", "shield-button");
|
|
auto cancelBtn = S.button("dialogs.shield", "cancel-button");
|
|
const float dp = Layout::dpiScale();
|
|
const bool isMerge = (s_mode == Mode::MergeToAddress);
|
|
// Merging needs the full node's z_mergetoaddress. Lite has no node: its scope scan used to fail and be
|
|
// re-posted every frame for as long as the dialog stayed open. (Shield coinbase keeps its own gating.)
|
|
if (isMerge && !app->supportsFullNodeLifecycleActions()) { s_open = false; return; }
|
|
|
|
const char* title = s_consolidate ? TR("consolidate_title")
|
|
: isMerge ? TR("merge_title")
|
|
: TR("shield_title");
|
|
|
|
material::OverlayDialogSpec ov;
|
|
ov.title = title; ov.p_open = &s_open;
|
|
ov.style = material::OverlayStyle::BlurFloat;
|
|
ov.cardWidth = win.width; ov.idSuffix = "shielddialog";
|
|
if (!material::BeginOverlayDialog(ov)) return;
|
|
|
|
const auto& state = app->getWalletState();
|
|
autoSelectDestination(state);
|
|
pollOperation(app);
|
|
pollPerAddress(app);
|
|
if (isMerge) {
|
|
services::ScopeInputs in;
|
|
in.mergeMode = true;
|
|
in.busyWithOp = !s_operation_id.empty() || s_operation_pending || !s_runs.empty();
|
|
in.nodeReady = app->walletScanReady();
|
|
in.now = services::steadySeconds();
|
|
if (services::scopeScanWant(s_scan, in) == services::ScopeWant::Post) loadScope(app);
|
|
}
|
|
|
|
// ── Description ──────────────────────────────────────────────────────────────────────────────
|
|
ImGui::TextWrapped("%s", s_consolidate ? TR("consolidate_desc")
|
|
: isMerge ? TR("merge_description")
|
|
: TR("shield_description"));
|
|
ImGui::Spacing();
|
|
|
|
// Submitted — inputs frozen, showing progress.
|
|
const bool opInFlight = !s_operation_id.empty() || s_runs_submitting || !s_runs.empty();
|
|
const bool perAddress = isMerge && s_per_address;
|
|
|
|
// ── Merge: scope + source selector ──────────────────────────────────────────────────────────
|
|
if (isMerge && !opInFlight) {
|
|
if (s_scan.phase == services::ScopePhase::Loading) {
|
|
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(),
|
|
TR("merge_scope_loading"));
|
|
} else if (s_scan.phase == services::ScopePhase::Loaded) {
|
|
char buf[160];
|
|
std::snprintf(buf, sizeof(buf), TR("merge_scope_fmt"),
|
|
s_t_count, s_z_count, fmtAmt(s_t_amount + s_z_amount).c_str());
|
|
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), buf);
|
|
} else if (s_scan.phase == services::ScopePhase::Failed) {
|
|
const std::string reason = s_scan.error.size() > 160 ? s_scan.error.substr(0, 160) + "..." : s_scan.error;
|
|
char buf[320];
|
|
std::snprintf(buf, sizeof(buf), TR("merge_scope_failed_fmt"), reason.c_str());
|
|
wrappedCaption(material::Warning(), buf);
|
|
if (material::TactileButton(TR("retry"), ImVec2(0, 0), S.resolveFont(cancelBtn.font)))
|
|
services::scopeScanRetry(s_scan);
|
|
} else {
|
|
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(),
|
|
state.isLocked() ? TR("merge_wait_locked") : TR("merge_waiting_node"));
|
|
}
|
|
ImGui::Spacing();
|
|
|
|
// Where the funds go: all into one address, or each address's notes back into that address.
|
|
ImGui::TextUnformatted(TR("merge_where")); // own line: the choices are long in some languages
|
|
int where = s_per_address ? 1 : 0;
|
|
ImGui::RadioButton(TR("merge_into_one"), &where, 0); ImGui::SameLine();
|
|
ImGui::RadioButton(TR("merge_keep_each"), &where, 1);
|
|
s_per_address = (where == 1);
|
|
ImGui::Spacing();
|
|
|
|
// Source selector — only offer the types that actually have inputs.
|
|
const bool tOk = s_t_count > 0, zOk = s_z_count > 0;
|
|
if (tOk && zOk && !s_per_address) {
|
|
ImGui::AlignTextToFramePadding();
|
|
ImGui::TextUnformatted(TR("merge_source"));
|
|
ImGui::SameLine(0, Layout::spacingLg());
|
|
ImGui::RadioButton(TR("merge_src_shielded"), &s_src, 1); ImGui::SameLine();
|
|
ImGui::RadioButton(TR("merge_src_transparent"), &s_src, 0); ImGui::SameLine();
|
|
ImGui::RadioButton(TR("merge_src_both"), &s_src, 2);
|
|
ImGui::Spacing();
|
|
}
|
|
|
|
// Keep each address's funds in place: pick the addresses. Shielded only — a transparent address
|
|
// can't take a merge's output, its funds can only be shielded ("Into one address").
|
|
if (s_per_address) {
|
|
wrappedCaption(material::OnSurfaceMedium(), TR("merge_keep_each_desc"));
|
|
ImGui::Spacing();
|
|
int listed = 0;
|
|
for (const auto& g : s_groups) if (g.notes >= 2) ++listed;
|
|
if (s_scan.phase == services::ScopePhase::Loaded && listed == 0) {
|
|
wrappedCaption(material::Warning(), TR("merge_keep_each_none"));
|
|
} else if (listed > 0) {
|
|
const float rowH = ImGui::GetFrameHeightWithSpacing();
|
|
const float listH = std::min(static_cast<float>(listed), 7.0f) * rowH + ImGui::GetStyle().WindowPadding.y * 2.0f;
|
|
if (ImGui::BeginChild("##ConsolidateEach", ImVec2(-1, listH), ImGuiChildFlags_Borders)) {
|
|
for (const auto& g : s_groups) {
|
|
if (g.notes < 2) continue;
|
|
const bool worth = services::worthConsolidating(g, s_fee);
|
|
bool ticked = worth && s_selected.count(g.address) > 0;
|
|
const std::string label = app->getAddressLabel(g.address);
|
|
char row[256];
|
|
std::snprintf(row, sizeof(row), TR("merge_keep_each_row_fmt"),
|
|
label.empty() ? shortAddr(g.address).c_str() : label.c_str(),
|
|
g.notes, fmtAmt(g.amount).c_str());
|
|
ImGui::PushID(g.address.c_str());
|
|
ImGui::BeginDisabled(!worth);
|
|
if (ImGui::Checkbox(row, &ticked)) {
|
|
if (ticked) { s_selected.insert(g.address); s_unticked.erase(g.address); }
|
|
else { s_selected.erase(g.address); s_unticked.insert(g.address); }
|
|
}
|
|
ImGui::EndDisabled();
|
|
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
|
material::Tooltip("%s%s%s", g.address.c_str(), worth ? "" : "\n\n",
|
|
worth ? "" : TR("merge_keep_each_too_small"));
|
|
ImGui::PopID();
|
|
}
|
|
}
|
|
ImGui::EndChild();
|
|
}
|
|
ImGui::Spacing();
|
|
}
|
|
}
|
|
|
|
// ── Shield coinbase: from address ───────────────────────────────────────────────────────────
|
|
if (!isMerge && !opInFlight) {
|
|
material::LabeledInput(TR("shield_from_address"), "##FromAddr", s_from_address, sizeof(s_from_address));
|
|
ImGui::TextDisabled("%s", TR("shield_wildcard_hint"));
|
|
ImGui::Spacing();
|
|
}
|
|
|
|
// ── Destination (z-address) ─────────────────────────────────────────────────────────────────
|
|
if (!opInFlight) {
|
|
if (!perAddress) {
|
|
ImGui::TextUnformatted(TR("shield_to_address"));
|
|
if (state.z_addresses.empty()) {
|
|
material::Type().textColored(material::TypeStyle::Caption, material::Warning(), TR("shield_no_zaddr_hint"));
|
|
ImGui::Spacing();
|
|
if (s_creating_addr) {
|
|
ImGui::TextDisabled("%s", TR("merge_creating"));
|
|
} else if (material::TactileButton(TR("merge_create_zaddr"), ImVec2(0, 0), S.resolveFont(shieldBtn.font))) {
|
|
s_creating_addr = true;
|
|
if (app->worker()) app->worker()->post([app, rpc = app->rpc()]() -> rpc::RPCWorker::MainCb {
|
|
std::string addr, error;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Shield dialog / New z-address");
|
|
addr = rpc->call("z_getnewaddress", nlohmann::json::array()).get<std::string>();
|
|
} catch (const std::exception& e) { error = e.what(); }
|
|
return [app, addr, error]() {
|
|
s_creating_addr = false;
|
|
if (error.empty() && !addr.empty()) {
|
|
strncpy(s_to_address, addr.c_str(), sizeof(s_to_address) - 1);
|
|
Notifications::instance().success(TR("merge_addr_created"));
|
|
} else {
|
|
Notifications::instance().error(std::string(TR("shield_error_prefix")) + error);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
} else {
|
|
std::string to_display = s_to_address[0] ? s_to_address : TR("shield_select_z");
|
|
if (to_display.length() > static_cast<size_t>(addrLbl.truncate))
|
|
to_display = to_display.substr(0, addrFront.truncate) + "..." + to_display.substr(to_display.length() - addrBack.truncate);
|
|
ImGui::SetNextItemWidth(-1);
|
|
if (ImGui::BeginCombo("##ToAddr", to_display.c_str())) {
|
|
for (size_t i = 0; i < state.z_addresses.size(); i++) {
|
|
std::string label = state.z_addresses[i].address;
|
|
if (label.length() > static_cast<size_t>(addrLbl.truncate))
|
|
label = label.substr(0, addrFront.truncate) + "..." + label.substr(label.length() - addrBack.truncate);
|
|
bool selected = (s_selected_zaddr_idx == static_cast<int>(i));
|
|
if (ImGui::Selectable(label.c_str(), selected)) {
|
|
s_selected_zaddr_idx = static_cast<int>(i);
|
|
strncpy(s_to_address, state.z_addresses[i].address.c_str(), sizeof(s_to_address) - 1);
|
|
}
|
|
if (selected) ImGui::SetItemDefaultFocus();
|
|
}
|
|
ImGui::EndCombo();
|
|
}
|
|
}
|
|
ImGui::Spacing();
|
|
} // !perAddress
|
|
|
|
// ── Advanced (fee + batch size) ─────────────────────────────────────────────────────────
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
material::CollapsibleHeader(dl, "##AdvToggle", TR("merge_advanced"), s_advanced,
|
|
ImGui::GetContentRegionAvail().x, material::Type().caption(),
|
|
material::OnSurfaceMedium());
|
|
if (s_advanced) {
|
|
ImGui::Spacing();
|
|
ImGui::TextUnformatted(TR("fee_label"));
|
|
ImGui::SetNextItemWidth(feeInput.width * dp);
|
|
ImGui::InputDouble("##Fee", &s_fee, 0.0001, 0.001, "%.8f");
|
|
if (s_fee < 0.0) s_fee = 0.0;
|
|
if (s_fee > 1.0) s_fee = 1.0;
|
|
ImGui::SameLine(); ImGui::TextDisabled("DRGX");
|
|
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), TR("merge_fee_hint"));
|
|
|
|
ImGui::Spacing();
|
|
ImGui::TextUnformatted(TR("merge_max_inputs"));
|
|
ImGui::SetNextItemWidth(utxoInput.width * dp);
|
|
ImGui::InputInt("##Limit", &s_utxo_limit);
|
|
if (s_utxo_limit < 1) s_utxo_limit = 1;
|
|
if (s_utxo_limit > 100) s_utxo_limit = 100;
|
|
}
|
|
|
|
// Batch hint: one run only merges up to the limit; large sets need repeats.
|
|
if (perAddress) {
|
|
bool multi = false;
|
|
for (const auto& g : selectedGroups()) if (services::estimateBatches(g.notes, s_utxo_limit) > 1) multi = true;
|
|
if (multi) {
|
|
char hb[200];
|
|
std::snprintf(hb, sizeof(hb), TR("merge_keep_each_batches_fmt"), s_utxo_limit);
|
|
ImGui::Spacing();
|
|
wrappedCaption(material::Warning(), hb);
|
|
}
|
|
} else if (isMerge && s_scan.phase == services::ScopePhase::Loaded && srcCount() > s_utxo_limit) {
|
|
char hb[160];
|
|
std::snprintf(hb, sizeof(hb), TR("merge_batch_fmt"), s_utxo_limit);
|
|
ImGui::Spacing();
|
|
material::Type().textColored(material::TypeStyle::Caption, material::Warning(), hb);
|
|
}
|
|
ImGui::Spacing();
|
|
}
|
|
|
|
// ── Per-address progress ────────────────────────────────────────────────────────────────────
|
|
if (!s_runs.empty()) {
|
|
bool leftovers = false;
|
|
for (const auto& r : s_runs) {
|
|
const std::string label = app->getAddressLabel(r.run.address);
|
|
const char* name = label.empty() ? nullptr : label.c_str();
|
|
const std::string shortA = shortAddr(r.run.address);
|
|
int done = 0, failed = 0, unknown = 0;
|
|
for (int st : r.state) { if (st == 1) ++done; if (st == 2) ++failed; if (st == 3) ++unknown; }
|
|
const int batches = static_cast<int>(r.run.opids.size());
|
|
char line[320];
|
|
ImU32 col = material::OnSurfaceMedium();
|
|
if (batches == 0) {
|
|
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_not_started"), name ? name : shortA.c_str(),
|
|
r.run.error.c_str());
|
|
col = material::Error();
|
|
} else if (failed > 0) {
|
|
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_failed"), name ? name : shortA.c_str(),
|
|
r.failure.c_str());
|
|
col = material::Error();
|
|
} else if (unknown > 0 && done + unknown == batches) {
|
|
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_unconfirmed"), name ? name : shortA.c_str());
|
|
col = material::Warning();
|
|
} else if (done == batches) {
|
|
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_done"), name ? name : shortA.c_str(),
|
|
r.run.notesMerged, batches);
|
|
col = material::Success();
|
|
} else {
|
|
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_running"), name ? name : shortA.c_str(),
|
|
r.run.notesMerged, done, batches);
|
|
}
|
|
wrappedCaption(col, line);
|
|
if (batches > 1 || r.run.remaining >= 2) leftovers = true;
|
|
}
|
|
if (leftovers) {
|
|
ImGui::Spacing();
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
|
|
ImGui::TextWrapped("%s", TR("merge_keep_each_run_again"));
|
|
ImGui::PopStyleColor();
|
|
}
|
|
ImGui::Spacing();
|
|
}
|
|
|
|
// ── Live progress / status ──────────────────────────────────────────────────────────────────
|
|
if (!s_status_message.empty()) {
|
|
if (s_operation_pending && !s_op_terminal)
|
|
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", s_status_message.c_str());
|
|
else
|
|
ImGui::TextWrapped("%s", s_status_message.c_str());
|
|
ImGui::Spacing();
|
|
}
|
|
|
|
// ── Footer ──────────────────────────────────────────────────────────────────────────────────
|
|
const bool connected = app->isConnected();
|
|
const bool syncing = state.sync.syncing;
|
|
const bool haveDest = s_to_address[0] != '\0';
|
|
|
|
if (opInFlight) {
|
|
// After submit: just a Close button (progress shows above; op continues in the background).
|
|
material::BeginOverlayDialogFooter(cancelBtn.width, /*drawSeparator=*/false);
|
|
if (material::TactileButton(s_op_terminal ? TR("done") : TR("close"),
|
|
ImVec2(cancelBtn.width, 0), S.resolveFont(cancelBtn.font)))
|
|
s_open = false;
|
|
material::EndOverlayDialog();
|
|
return;
|
|
}
|
|
|
|
const char* primaryLabel = s_confirm ? TR("merge_confirm_btn")
|
|
: s_consolidate ? TR("consolidate_funds_btn")
|
|
: isMerge ? TR("merge_funds")
|
|
: TR("shield_funds");
|
|
const char* secondaryLabel = s_confirm ? TR("merge_back") : TR("cancel");
|
|
|
|
// Confirm summary (inline, before the fund-moving call). Merge/consolidate shows amount + input
|
|
// count; shield-coinbase just gets the button relabel (its inputs aren't enumerated here).
|
|
if (s_confirm && perAddress) {
|
|
int notes = 0, batches = 0;
|
|
const auto sel = selectedGroups();
|
|
for (const auto& g : sel) { notes += g.notes; batches += services::estimateBatches(g.notes, s_utxo_limit); }
|
|
char cb[320];
|
|
std::snprintf(cb, sizeof(cb), TR("merge_keep_each_confirm_fmt"), notes, static_cast<int>(sel.size()),
|
|
fmtAmt(s_fee * batches).c_str());
|
|
ImGui::TextWrapped("%s", cb);
|
|
ImGui::Spacing();
|
|
} else if (s_confirm && isMerge) {
|
|
char cb[200];
|
|
std::snprintf(cb, sizeof(cb), TR("merge_confirm_fmt"),
|
|
fmtAmt(srcAmount()).c_str(), srcCount(), shortAddr(s_to_address).c_str());
|
|
ImGui::TextWrapped("%s", cb);
|
|
ImGui::Spacing();
|
|
}
|
|
|
|
bool can_submit = haveDest && connected && !syncing;
|
|
const bool scopeLoaded = s_scan.phase == services::ScopePhase::Loaded;
|
|
if (isMerge && scopeLoaded && srcCount() == 0) can_submit = false;
|
|
if (perAddress) can_submit = connected && !syncing && scopeLoaded && !selectedGroups().empty();
|
|
|
|
float footerBtnW = shieldBtn.width + cancelBtn.width + ImGui::GetStyle().ItemSpacing.x;
|
|
material::BeginOverlayDialogFooter(footerBtnW, /*drawSeparator=*/false);
|
|
|
|
if (!can_submit) ImGui::BeginDisabled();
|
|
if (material::TactileButton(primaryLabel, ImVec2(shieldBtn.width, 0), S.resolveFont(shieldBtn.font))) {
|
|
if (s_confirm) { if (perAddress) submitPerAddress(app); else submitOperation(app); }
|
|
else { s_confirm = true; } // first click → show the confirm summary
|
|
}
|
|
if (!can_submit) ImGui::EndDisabled();
|
|
if (!can_submit && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
|
|
if (!connected) material::Tooltip("%s", TR("send_tooltip_not_connected"));
|
|
else if (syncing) material::Tooltip("%s", TR("send_tooltip_syncing"));
|
|
else if (perAddress) material::Tooltip("%s", TR("merge_keep_each_select"));
|
|
else if (!haveDest) material::Tooltip("%s", TR("shield_select_z"));
|
|
else if (isMerge && srcCount() == 0) material::Tooltip("%s", TR("merge_no_spendable"));
|
|
}
|
|
|
|
ImGui::SameLine();
|
|
if (material::TactileButton(secondaryLabel, ImVec2(cancelBtn.width, 0), S.resolveFont(cancelBtn.font))) {
|
|
if (s_confirm) s_confirm = false; // Back → return to the form
|
|
else s_open = false; // Cancel → close
|
|
}
|
|
|
|
material::EndOverlayDialog();
|
|
}
|
|
|
|
} // namespace ui
|
|
} // namespace dragonx
|