10 Commits

Author SHA1 Message Date
0942691eb3 fix(win): route shell-outs through a windowless helper (no cmd.exe flash)
_popen/_popen-style shell-outs flash a cmd.exe console window on Windows. Add
Platform::runHiddenCapture() — CreateProcess + CREATE_NO_WINDOW capturing stdout on
Windows, popen on POSIX — and route the remaining shell-outs through it:
- GPU-aware idle detection (getGpuUtilization: "where nvidia-smi" / "nvidia-smi --query-gpu")
- xmrig discovery + version (findXmrigBinary "where xmrig.exe"; "<bin> --version", stderr merged)
- wallet-rebuild helper (app_network) — keeps its exit-code check via the new exitCode out-param

None of these are on the launch path (that was the daemon spawn, fixed in a2f84be); each
would flash a console only when it ran (idle-GPU mining, mining tab, wallet recovery).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-30 23:58:54 -05:00
a2f84be2d4 fix(win): stop console-window flash on launch (spawn daemon with CREATE_NO_WINDOW)
The embedded daemon was launched with CREATE_NEW_CONSOLE + SW_HIDE. CREATE_NEW_CONSOLE
allocates a console window that flashes on screen before SW_HIDE hides it — visible as a
console-window flash every time the wallet starts dragonxd (i.e. on launch). Switch to
CREATE_NO_WINDOW (the console child gets no window at all, matching the xmrig launcher);
dragonxd logs to debug.log, not a console, so nothing is lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-30 23:43:12 -05:00
7e8b99a82b feat(shutdown): confirm before stopping the daemon mid witness-cache rebuild
Stopping dragonxd while it's rebuilding the Sapling witness cache discards the
in-progress work — BuildWitnessCache aborts on shutdown without persisting — so
the next launch redoes a multi-minute rebuild (the "Activating best chain…" hang).
This bites especially with stop_external_daemon enabled, where wallet exit sends
the node a stop.

beginShutdown() now defers when it would StopDaemon while a rebuild is active and
shows a confirm modal: "Keep node running & quit" (DisconnectOnly — leaves it up
to finish), "Stop anyway & quit", or "Cancel". Rebuild detection reads the
debug.log tail markers (Cleared witness data / Setting Initial Sapling Witness /
Reading blocks for witness rebuild, vs. the "rebuilt … in …ms" / abort lines).
The gate lives entirely in beginShutdown()/render() — no SDL event-loop changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-30 23:32:03 -05:00
29274c2f48 fix(ui): trim verbose startup notice; show daemon output on shutdown for external daemons
- Loading "taking longer than expected" notice: shorten the body + hint so the
  startup screen reads less wordy (same info, ~half the text).
- Shutdown screen: when the wallet attached to an EXTERNAL daemon (no captured
  stdout — debug_log_path_ is only set when we spawn it), the "dragonxd output"
  panel was always empty, leaving just a spinner. Fall back to tailing the
  daemon's debug.log so the user can watch the node flush the block index and
  exit. Adds App::tailDaemonDebugLog() (best-effort, reads only the file tail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-30 23:09:17 -05:00
870793433b fix(sync): stop large-wallet balance polling from starving block connection
On a fully-shielded (ac_private=1) chain, z_gettotalbalance is O(mapWallet) and
holds the daemon's cs_main for its whole duration — ~20s on a ~5k-tx wallet. The
Overview refresh polled it every ~2s (twice: minconf 0 and 1), so cs_main was
held almost continuously, starving the single block-connection thread: the node
connected blocks only in the gaps between polls and could fall further behind
the tip than it caught up (observed live: gap growing 58→100 blocks while the
GUI was open, one core pegged on GetFilteredNotes, 22 idle, ~17 B/s download).

Two hardening changes on top of the existing "skip balance while syncing" guard:

- Hysteresis: keep the low-impact sync profile (and balance suppression) for a
  short settle window after catching up, so a large-wallet scan can't
  immediately re-starve connection and bounce the node back into syncing. Armed
  only on the syncing→caught-up edge, so a wallet synced from the start is never
  throttled at connect (effectivelySyncing()).

- Adaptive balance cadence: time each z_gettotalbalance scan and require the
  next poll to wait at least (cost / 10%), so balance scanning never occupies
  more than ~10% of wall-clock. Cheap wallets are unaffected (the tab's Core
  timer stays the cadence); a ~20s scan backs off to ~200s. Wallet mutations
  (send/shield) force the next poll through so the user's own action updates the
  balance immediately (balanceRefreshDue()).

getblockchaininfo keeps its normal cadence throughout, so sync progress stays
live. Build + test_phase4 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-30 22:45:51 -05:00
08cfeb0e08 feat(ui): rework the consolidate/merge modal
Make Merge to Address actually serve wallet-bloat consolidation and be far less
opaque. New ShieldDialog::showConsolidate() preset (used by the large-wallet
Settings banner + alert action) frames it as "Consolidate funds" and targets
shielded notes — the bloat the nudge warns about.

- Source selector: consolidate shielded notes (ANY_SAPLING), transparent
  (ANY_TADDR), or both (*) — previously hardcoded to ANY_TADDR, which never
  reduced the shielded-witness bloat. Batch limit now applies to the right side.
- Scope: on open, count spendable UTXOs + notes (listunspent / z_listunspent)
  and show "N transparent + M shielded · ~X DRGX"; warn "repeat to finish" when
  the set exceeds one batch.
- Destination auto-selects the best spendable z-address (button enabled by
  default); empty wallets get an inline "Create shielded address" (z_getnewaddress).
- Advanced disclosure hides Fee + "Max inputs per batch" (renamed from the "UTXO
  Limit" jargon) with sane defaults.
- Inline confirm step before the fund-moving call (amount + input count + dest).
- Live progress: self-polls z_getoperationstatus to show Consolidating… →
  Done/Failed, replacing the raw opid + manual "Check status" button.

All three merge entry points now use the typed showMerge()/showConsolidate()
(no stale-static leaks from direct show(MergeToAddress)). Shield-coinbase mode
keeps working. New i18n keys fall back to English.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 22:33:59 -05:00
5daf2d83b6 feat(ui): large-wallet nudge as a one-time toast + clickable alert
Extend the wallet-bloat warning beyond the Settings banner: when wallet.dat
first crosses 500 MB (full-node, synced), fire a one-time warning toast plus a
clickable "Consolidate notes…" entry in the bell/alerts panel that opens Merge
to Address. The persisted large_wallet_warned flag keeps it once-only and
re-arms if the file later shrinks back under the threshold.

- AlertRecord gains an optional onClick + actionHint; Notifications::action()
  pushes a toast and a clickable history entry. renderAlertHistoryPanel() now
  renders the accent action link (under the message) and measures true content
  height so wrapped messages + the link aren't clipped.
- App::maybeWarnLargeWallet() (mirrors maybeRemindSeedBackup) runs once per
  launch from update(); reuses the existing wallet_size_warn/consolidate strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 18:09:36 -05:00
6d26ccd0ed feat(ui): large-wallet nudge in Node & Security
The BDB wallet.dat bloats with shielded-note witness data and never shrinks
in place, so a mining/shielded wallet can grow past 500 MB. Below the Wallet
Size row, show a one-line amber hint once wallet.dat crosses 500 MB with a
"Consolidate notes…" shortcut that opens the Merge to Address (z_mergetoaddress)
dialog. Full-node only (lite has no wallet.dat here); threshold is a single
named constant. i18n keys fall back to English for non-English locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 17:41:25 -05:00
a7514becbc feat(ui): credit The DragonX Developers in the About tab
Add "The DragonX Developers" to the About-tab credits (after The Hush
Developers), acknowledging the DragonX chain/daemon this wallet drives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 17:17:03 -05:00
558cfcbe56 fix(ui): restore ObsidianDragon logo in header and About tab
ensureLogoTexture() rasterized the embedded DragonX SVG into logo_tex_ and
returned early (added in 1752500 "themed DragonX logo"), so the app/product
branding — the top-left header (app.cpp AddImage) and the About tab
(getLogoTexture) — showed the DragonX coin mark instead of the ObsidianDragon
logo. Drop that step so logo_tex_ resolves via the intended path: active-skin
override → ui.toml header-icon → bundled ObsidianDragon dark/light PNG (disk,
then embedded RESOURCE_LOGO). The DragonX SVG stays for coin_logo_tex_ (balance
card) and drgx_emoji_tex_ (chat emoji), which are the currency mark and correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 17:06:04 -05:00
16 changed files with 903 additions and 365 deletions

View File

@@ -878,6 +878,9 @@ void App::update()
// (a prior/unwitnessed salvage likely moved the coins into a wallet.<ts>.bak). // (a prior/unwitnessed salvage likely moved the coins into a wallet.<ts>.bak).
maybeWarnEmptyWalletWithFundedSiblings(); maybeWarnEmptyWalletWithFundedSiblings();
// One-time nudge if wallet.dat has bloated past the threshold (toast + clickable alert → consolidate).
maybeWarnLargeWallet();
// Classify the wallet's mnemonic status (once per connect) so the Migrate-to-seed button can // Classify the wallet's mnemonic status (once per connect) so the Migrate-to-seed button can
// glow for a legacy, pre-seed-phrase wallet. // glow for a legacy, pre-seed-phrase wallet.
probeWalletSeedStatus(); probeWalletSeedStatus();
@@ -916,7 +919,9 @@ void App::update()
// Re-apply the refresh cadence when sync starts/finishes: while syncing we throttle polling to // Re-apply the refresh cadence when sync starts/finishes: while syncing we throttle polling to
// a low-impact profile so RPC contention doesn't slow block download (see applyRefreshPolicy). // a low-impact profile so RPC contention doesn't slow block download (see applyRefreshPolicy).
if (state_.sync.syncing != refresh_policy_syncing_) { // effectivelySyncing() includes the post-sync settle window, so this also reverts to the normal
// per-tab cadence once that window elapses.
if (effectivelySyncing() != refresh_policy_syncing_) {
applyRefreshPolicy(current_page_); applyRefreshPolicy(current_page_);
} }
@@ -1502,16 +1507,11 @@ void App::ensureLogoTexture()
} }
} }
// 0) DragonX mark — rasterize the embedded SVG recolored to the theme (body = accent, detail = white) // The header / top-left / About branding is the ObsidianDragon PRODUCT logo — NOT the DragonX coin
// at ~2x the 128px viewBox for crisp downscaling. This is the branding on every skin; the per-skin // mark (that is coin_logo_tex_ / drgx_emoji_tex_ above). Resolve it below: active-skin override, else
// PNG path below is only a fallback if rasterization ever fails. // the ui.toml header-icon, else the bundled ObsidianDragon dark/light PNG (disk, then embedded).
if (util::LoadTextureFromSvg(embedded::kLogoDragonXSvg, 256, logoAccent,
detailCol, &logo_tex_, &logo_w_, &logo_h_)) {
DEBUG_LOGF("Rendered DragonX SVG logo (%dx%d, accent %08X)\n", logo_w_, logo_h_, logoAccent);
return;
}
// 1) Fallback — theme-override logo from the active skin // 1) theme-override logo from the active skin
const auto* activeSkin = ui::schema::SkinManager::instance().findById( const auto* activeSkin = ui::schema::SkinManager::instance().findById(
ui::schema::SkinManager::instance().activeSkinId()); ui::schema::SkinManager::instance().activeSkinId());
std::string logoPath; std::string logoPath;
@@ -2224,6 +2224,7 @@ void App::render()
renderDecryptWalletDialog(); renderDecryptWalletDialog();
renderPinDialogs(); renderPinDialogs();
renderSwitchStopDaemonDialog(); renderSwitchStopDaemonDialog();
renderDaemonStopConfirm();
renderBlockDbReindexDialog(); renderBlockDbReindexDialog();
renderWalletRecoveredDialog(); renderWalletRecoveredDialog();
renderEmptyWalletWarningDialog(); renderEmptyWalletWarningDialog();
@@ -2426,10 +2427,19 @@ void App::renderAlertHistoryPanel()
return; return;
} }
// Scrollable list, newest first. Height adapts to the entry count but caps so a busy session // Scrollable list, newest first. Measure the TRUE content height so wrapped (multi-line) messages
// scrolls inside the panel instead of blowing past the popup's max height. // and optional action links aren't clipped by an under-estimate; cap so a busy session scrolls
const float perEntry = txtF->LegacySize * 2.0f + 14.0f * dp; // message line + time line + spacing // inside the panel instead of blowing past the popup's max height.
const float listH = std::min(300.0f * dp, static_cast<float>(hist.size()) * perEntry); const float msgWrapW = std::max(40.0f * dp, innerW - 2.0f * padX - icoF->LegacySize - 6.0f * dp);
float contentH = 0.0f;
for (const auto& a : hist) {
const float msgH = txtF->CalcTextSizeA(txtF->LegacySize, FLT_MAX, msgWrapW, a.message.c_str()).y;
contentH += std::max(msgH, static_cast<float>(icoF->LegacySize)); // icon + wrapped message
contentH += txtF->LegacySize; // relative-age line
if (a.onClick && !a.actionHint.empty()) contentH += txtF->LegacySize; // action-link line
contentH += 8.0f * dp; // inter-entry spacing
}
const float listH = std::min(300.0f * dp, contentH);
ImGui::BeginChild("##AlertRows", ImVec2(0, listH), false); ImGui::BeginChild("##AlertRows", ImVec2(0, listH), false);
int idx = 0; int idx = 0;
for (auto it = hist.rbegin(); it != hist.rend(); ++it, ++idx) { for (auto it = hist.rbegin(); it != hist.rend(); ++it, ++idx) {
@@ -2457,6 +2467,19 @@ void App::renderAlertHistoryPanel()
ImGui::TextWrapped("%s", a.message.c_str()); ImGui::TextWrapped("%s", a.message.c_str());
ImGui::PopTextWrapPos(); ImGui::PopTextWrapPos();
ImGui::PopStyleColor(); ImGui::PopStyleColor();
// Optional clickable action (accent link), directly under the message so it stays prominent.
if (a.onClick && !a.actionHint.empty()) {
ImGui::SetCursorPosX(padX + icoF->LegacySize + 6.0f * dp);
ImGui::PushStyleColor(ImGuiCol_Text, m::Primary());
ImGui::TextUnformatted(a.actionHint.c_str());
ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) {
const ImVec2 lmn = ImGui::GetItemRectMin(), lmx = ImGui::GetItemRectMax();
ImGui::GetWindowDrawList()->AddLine(ImVec2(lmn.x, lmx.y), ImVec2(lmx.x, lmx.y), m::Primary());
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
if (ImGui::IsItemClicked()) { a.onClick(); ImGui::CloseCurrentPopup(); }
}
// Relative age, dim, indented under the message. // Relative age, dim, indented under the message.
ImGui::SetCursorPosX(padX + icoF->LegacySize + 6.0f * dp); ImGui::SetCursorPosX(padX + icoF->LegacySize + 6.0f * dp);
ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceDisabled()); ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceDisabled());
@@ -5630,6 +5653,16 @@ void App::beginShutdown()
{ {
// Only start shutdown once // Only start shutdown once
if (shutting_down_) return; if (shutting_down_) return;
// Guard: don't silently discard an in-progress witness-cache rebuild. If we're about to stop the
// daemon while it's rebuilding (stopping now forces a multi-minute rebuild on the next launch),
// defer shutdown and let render() show the confirm modal. The user's choice re-enters beginShutdown()
// with shutdown_confirmed_ set (and, for "keep node running", shutdown_keep_daemon_override_).
if (!shutdown_confirmed_ && shouldConfirmDaemonStop()) {
pending_shutdown_confirm_ = true;
return; // NOT shutting down yet — the normal UI + modal keep rendering
}
shutting_down_ = true; shutting_down_ = true;
quit_requested_ = true; quit_requested_ = true;
shutdown_timer_ = 0.0f; shutdown_timer_ = 0.0f;
@@ -5690,7 +5723,7 @@ void App::beginShutdown()
} }
auto shutdownDecision = daemon_controller_->shutdownDecision( auto shutdownDecision = daemon_controller_->shutdownDecision(
settings_ && settings_->getKeepDaemonRunning(), (settings_ && settings_->getKeepDaemonRunning()) || shutdown_keep_daemon_override_,
settings_ && settings_->getStopExternalDaemon()); settings_ && settings_->getStopExternalDaemon());
if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) { if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) {
DEBUG_LOGF("beginShutdown: %s, skipping daemon stop\n", shutdownDecision.logReason); DEBUG_LOGF("beginShutdown: %s, skipping daemon stop\n", shutdownDecision.logReason);
@@ -5720,6 +5753,133 @@ void App::beginShutdown()
}); });
} }
std::vector<std::string> App::tailDaemonDebugLog(int maxLines) const
{
std::vector<std::string> out;
if (maxLines <= 0) return out;
const std::string path = util::Platform::getDataDir() + "debug.log";
std::error_code ec;
const auto sz = std::filesystem::file_size(path, ec);
if (ec || sz == 0) return out;
std::ifstream f(path, std::ios::binary);
if (!f) return out;
// Read only the last ~16 KB — plenty for a handful of lines, cheap even for a multi-GB log.
const std::uintmax_t kTailBytes = 16 * 1024;
const std::uintmax_t start = sz > kTailBytes ? sz - kTailBytes : 0;
f.seekg(static_cast<std::streamoff>(start), std::ios::beg);
std::string chunk(static_cast<std::size_t>(sz - start), '\0');
f.read(&chunk[0], static_cast<std::streamsize>(chunk.size()));
chunk.resize(static_cast<std::size_t>(f.gcount()));
std::vector<std::string> lines;
std::string cur;
for (char c : chunk) {
if (c == '\n') { if (!cur.empty()) lines.push_back(cur); cur.clear(); }
else if (c != '\r') cur.push_back(c);
}
if (!cur.empty()) lines.push_back(cur);
// When we seeked into the middle of the file the first line is a fragment — drop it.
if (start > 0 && !lines.empty()) lines.erase(lines.begin());
if (static_cast<int>(lines.size()) > maxLines)
lines.erase(lines.begin(), lines.end() - static_cast<std::ptrdiff_t>(maxLines));
return lines;
}
bool App::daemonWitnessRebuildActive() const
{
// Scan the debug.log tail for the daemon's witness-rebuild markers (wallet.cpp): "Cleared witness
// data from" (start), "Setting Initial Sapling Witness" / "Reading blocks for witness rebuild"
// (progress), vs. "rebuilt N note witness cache(s)" / "aborting…" (finished). Active iff the most
// recent relevant line is a start/progress line, not a completion.
const auto lines = tailDaemonDebugLog(80);
int state = 0; // 0 none, 1 active, 2 finished/aborted
for (const auto& l : lines) {
if (l.find("note witness cache(s) to height") != std::string::npos ||
l.find("aborting witness rebuild") != std::string::npos ||
l.find("aborted during witness rebuild") != std::string::npos) {
state = 2;
} else if (l.find("Reading blocks for witness rebuild") != std::string::npos ||
l.find("Setting Initial Sapling Witness") != std::string::npos ||
l.find("Cleared witness data from") != std::string::npos) {
state = 1;
}
}
return state == 1;
}
bool App::shouldConfirmDaemonStop() const
{
if (!daemon_controller_) return false;
// Only relevant when this shutdown would actually STOP the daemon (embedded, or external with
// stop-on-exit) — a DisconnectOnly shutdown leaves it running and loses nothing.
const auto decision = daemon_controller_->shutdownDecision(
settings_ && settings_->getKeepDaemonRunning(),
settings_ && settings_->getStopExternalDaemon());
if (decision.action != daemon::DaemonController::ShutdownAction::StopDaemon) return false;
return daemonWitnessRebuildActive();
}
void App::renderDaemonStopConfirm()
{
using namespace ui::material;
if (pending_shutdown_confirm_) {
ImGui::OpenPopup("##DaemonStopConfirm");
pending_shutdown_confirm_ = false;
daemon_stop_confirm_open_ = true;
}
if (!daemon_stop_confirm_open_) return;
ImVec2 center = ImGui::GetMainViewport()->GetCenter();
ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
bool proceed = false;
if (ImGui::BeginPopupModal("##DaemonStopConfirm", nullptr,
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
if (Type().subtitle1()) ImGui::PushFont(Type().subtitle1());
ImGui::TextUnformatted("Node is rebuilding its witness cache");
if (Type().subtitle1()) ImGui::PopFont();
ImGui::Spacing();
ImGui::PushTextWrapPos(ImGui::GetFontSize() * 26.0f);
ImGui::TextUnformatted(
"Stopping the node now discards the in-progress rebuild and restarts it (several minutes) "
"the next time you open the wallet. You can keep the node running instead.");
ImGui::PopTextWrapPos();
ImGui::Spacing();
ImGui::Spacing();
if (TactileButton("Keep node running & quit", ImVec2(0, 0))) {
shutdown_keep_daemon_override_ = true;
shutdown_confirmed_ = true;
daemon_stop_confirm_open_ = false;
proceed = true;
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 210)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(Error()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 160)));
const bool stopAnyway = TactileButton("Stop anyway & quit", ImVec2(0, 0));
ImGui::PopStyleColor(3);
if (stopAnyway) {
shutdown_confirmed_ = true;
daemon_stop_confirm_open_ = false;
proceed = true;
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (TactileButton("Cancel", ImVec2(0, 0))) {
daemon_stop_confirm_open_ = false; // abort the quit; stay open
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
} else {
daemon_stop_confirm_open_ = false; // dismissed via Esc / click-away = Cancel
}
// Re-enter shutdown outside the popup scope now that the user has chosen (shutdown_confirmed_ set).
if (proceed) beginShutdown();
}
void App::renderShutdownScreen() void App::renderShutdownScreen()
{ {
using namespace ui::material; using namespace ui::material;
@@ -5952,6 +6112,10 @@ void App::renderShutdownScreen()
// ------------------------------------------------------------------- // -------------------------------------------------------------------
if (daemon_controller_) { if (daemon_controller_) {
auto lines = daemon_controller_->recentLines(8); auto lines = daemon_controller_->recentLines(8);
// External daemon (attached, not spawned) has no captured stdout — tail its debug.log directly
// so the user can still watch the node flush the block index and exit.
if (lines.empty())
lines = tailDaemonDebugLog(8);
if (!lines.empty()) { if (!lines.empty()) {
float panelW = vp_size.x * shutElem("panel-width-fraction", 0.70f); float panelW = vp_size.x * shutElem("panel-width-fraction", 0.70f);
float panelX = cx - panelW * 0.5f; float panelX = cx - panelW * 0.5f;

View File

@@ -175,6 +175,21 @@ public:
*/ */
void renderShutdownScreen(); void renderShutdownScreen();
/**
* @brief Tail the last N lines of the daemon's debug.log (best-effort, reads only the file tail).
* Fallback for the shutdown screen when we have no captured stdout — e.g. an external daemon we
* attached to rather than spawned — so the user can still see the node flushing/exiting.
*/
std::vector<std::string> tailDaemonDebugLog(int maxLines) const;
// True when the daemon's debug.log shows an in-progress Sapling witness-cache rebuild (best-effort
// heuristic). Stopping the daemon during one discards it and forces a multi-minute redo next launch.
bool daemonWitnessRebuildActive() const;
// Whether beginShutdown() should pause and confirm before stopping the daemon (rebuild in progress).
bool shouldConfirmDaemonStop() const;
// The "node is rebuilding — stop anyway / keep running / cancel" modal, rendered from render().
void renderDaemonStopConfirm();
/** /**
* @brief Render loading overlay in content area while daemon is starting/syncing * @brief Render loading overlay in content area while daemon is starting/syncing
* @param contentH Height of the content area child window * @param contentH Height of the content area child window
@@ -817,6 +832,7 @@ private:
// install) to back up their seed phrase. Cheap early-outs keep it idle until it can act. // install) to back up their seed phrase. Cheap early-outs keep it idle until it can act.
void maybeRemindSeedBackup(); void maybeRemindSeedBackup();
void maybeWarnEmptyWalletWithFundedSiblings(); // full-node: empty active wallet + a funded sibling → warn once void maybeWarnEmptyWalletWithFundedSiblings(); // full-node: empty active wallet + a funded sibling → warn once
void maybeWarnLargeWallet(); // full-node: wallet.dat past bloat threshold → one-time toast + clickable alert
void scanFundedSiblingsAsync(); // off-UI-thread probe of sibling wallet files void scanFundedSiblingsAsync(); // off-UI-thread probe of sibling wallet files
static void scrubAndRemoveExport(const std::string& path); // zero + delete a plaintext key export (H-02) static void scrubAndRemoveExport(const std::string& path); // zero + delete a plaintext key export (H-02)
void sweepStaleDecryptExports(); // startup net: purge stale obsidiandecryptexport* files (H-02) void sweepStaleDecryptExports(); // startup net: purge stale obsidiandecryptexport* files (H-02)
@@ -903,6 +919,11 @@ private:
bool address_list_dirty_ = false; // P8: dedup rebuildAddressList bool address_list_dirty_ = false; // P8: dedup rebuildAddressList
GuardedStatus shutdown_status_; // thread-safe: written by the shutdown thread, read by the UI (M-05) GuardedStatus shutdown_status_; // thread-safe: written by the shutdown thread, read by the UI (M-05)
std::thread shutdown_thread_; std::thread shutdown_thread_;
// Confirm-before-stopping-daemon-mid-witness-rebuild guard (see beginShutdown / renderDaemonStopConfirm)
bool pending_shutdown_confirm_ = false; // a quit is deferred, waiting to open the confirm modal
bool daemon_stop_confirm_open_ = false; // the confirm modal is currently showing
bool shutdown_confirmed_ = false; // user chose to proceed — bypass the guard on re-entry
bool shutdown_keep_daemon_override_ = false; // user chose "keep node running" for this shutdown only
float shutdown_timer_ = 0.0f; float shutdown_timer_ = 0.0f;
bool force_quit_confirm_ = false; bool force_quit_confirm_ = false;
std::chrono::steady_clock::time_point shutdown_start_time_; std::chrono::steady_clock::time_point shutdown_start_time_;
@@ -1007,6 +1028,7 @@ private:
bool seed_backup_loading_ = false; bool seed_backup_loading_ = false;
bool seed_backup_no_mnemonic_ = false; bool seed_backup_no_mnemonic_ = false;
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
bool large_wallet_checked_ = false; // gate: stat wallet.dat for the bloat nudge once per launch
// Cached mnemonic status of the current wallet, driving the Migrate-to-seed button glow. Probed // Cached mnemonic status of the current wallet, driving the Migrate-to-seed button glow. Probed
// once per connect (probeWalletSeedStatus, via exportSeedPhrase); NoMnemonic = a legacy wallet a // once per connect (probeWalletSeedStatus, via exportSeedPhrase); NoMnemonic = a legacy wallet a
@@ -1100,6 +1122,14 @@ private:
bool daemon_start_error_shown_ = false; bool daemon_start_error_shown_ = false;
int daemon_last_seen_crashes_ = 0; // surface each new embedded-daemon crash reason once int daemon_last_seen_crashes_ = 0; // surface each new embedded-daemon crash reason once
bool refresh_policy_syncing_ = false; // whether the sync-throttle refresh profile is active bool refresh_policy_syncing_ = false; // whether the sync-throttle refresh profile is active
// Sync-settle hysteresis + adaptive balance-poll throttle. Balance polling (z_gettotalbalance) is
// O(mapWallet) and holds the daemon's cs_main, which starves block connection on a large shielded
// wallet — so we keep the low-impact profile briefly after catching up, and back the balance poll
// off in proportion to its own measured cost. See effectivelySyncing() / balanceRefreshDue().
bool was_core_syncing_ = false; // previous Core-refresh sync state, to detect the caught-up edge
std::time_t sync_settle_until_ = 0; // hold the sync-throttle until this wall-clock time (0 = not settling)
double last_balance_scan_ms_ = 0.0; // measured cost of the last z_gettotalbalance scan
bool force_balance_refresh_ = false; // a wallet mutation forces the next balance poll through the throttle
// Auto-clear for secrets copied to the clipboard. Only a hash of the copied secret is kept. // Auto-clear for secrets copied to the clipboard. Only a hash of the copied secret is kept.
std::uint64_t clipboard_secret_hash_ = 0; std::uint64_t clipboard_secret_hash_ = 0;
double clipboard_clear_deadline_ = 0.0; double clipboard_clear_deadline_ = 0.0;
@@ -1437,6 +1467,8 @@ private:
void refreshPrice(); void refreshPrice();
void refreshWalletEncryptionState(); void refreshWalletEncryptionState();
void applyRefreshPolicy(ui::NavPage page); void applyRefreshPolicy(ui::NavPage page);
bool effectivelySyncing() const; // syncing, or within the post-sync settle window (hysteresis)
bool balanceRefreshDue() const; // adaptive: enough time elapsed given the last balance-scan cost?
bool currentPageNeedsWalletDataRefresh() const; bool currentPageNeedsWalletDataRefresh() const;
bool shouldRunWalletTransactionRefresh() const; bool shouldRunWalletTransactionRefresh() const;
bool shouldRefreshTransactions() const; bool shouldRefreshTransactions() const;

View File

@@ -35,6 +35,7 @@
#include "rpc/connection.h" #include "rpc/connection.h"
#include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning #include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning
#include "ui/windows/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch #include "ui/windows/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch
#include "ui/windows/shield_dialog.h" // ui::ShieldDialog — Merge to Address shortcut from the bloat nudge
#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress #include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic #include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
#include <cctype> #include <cctype>
@@ -836,13 +837,39 @@ void App::applyRefreshPolicy(ui::NavPage page)
// While the daemon is syncing, override the per-tab cadence with the low-impact sync profile so // While the daemon is syncing, override the per-tab cadence with the low-impact sync profile so
// the wallet stops contending for the daemon's cs_main lock (frequent getpeerinfo / per-block // the wallet stops contending for the daemon's cs_main lock (frequent getpeerinfo / per-block
// transaction scans / balance polls slow block connection). This makes every tab sync as fast // transaction scans / balance polls slow block connection). This makes every tab sync as fast
// as the Console tab does today. Reverts to the per-tab profile once sync finishes. // as the Console tab does today. effectivelySyncing() keeps this profile on briefly after catching
refresh_policy_syncing_ = state_.sync.syncing; // up (hysteresis) so a large-wallet scan can't immediately re-starve connection and bounce the
// node back into "syncing". Reverts to the per-tab profile once the settle window passes.
refresh_policy_syncing_ = effectivelySyncing();
network_refresh_.setIntervals(refresh_policy_syncing_ network_refresh_.setIntervals(refresh_policy_syncing_
? services::RefreshScheduler::kSyncProfile ? services::RefreshScheduler::kSyncProfile
: getIntervalsForPage(page)); : getIntervalsForPage(page));
} }
// True while the node is behind, and for a short settle window after it first catches up. The settle
// window is armed only on the syncing→caught-up edge (see the Core refresh callback), so a wallet that
// was synced from the start is never throttled at connect — only a node that just finished catching up.
bool App::effectivelySyncing() const
{
if (state_.sync.syncing) return true;
if (sync_settle_until_ == 0) return false; // no pending settle → genuinely caught up
return std::time(nullptr) < sync_settle_until_;
}
// Adaptive throttle: the next balance poll must wait at least (lastScanCost / kBalanceDutyCycle) since
// the last one, so balance scanning can never occupy more than ~kBalanceDutyCycle of wall-clock. A
// cheap wallet (sub-cadence cost) is unaffected — the tab's Core timer stays the real cadence; a ~20s
// scan on a large wallet backs off to roughly every ~200s instead of every 2s, freeing cs_main for
// block connection. A wallet mutation bypasses this via force_balance_refresh_.
bool App::balanceRefreshDue() const
{
constexpr double kBalanceDutyCycle = 0.10;
if (state_.last_balance_update == 0) return true; // never fetched
if (last_balance_scan_ms_ <= 0.0) return true; // no cost measured yet
const double minInterval = (last_balance_scan_ms_ / 1000.0) / kBalanceDutyCycle;
return std::difftime(std::time(nullptr), state_.last_balance_update) >= minInterval;
}
bool App::currentPageNeedsWalletDataRefresh() const bool App::currentPageNeedsWalletDataRefresh() const
{ {
using NP = ui::NavPage; using NP = ui::NavPage;
@@ -1665,9 +1692,15 @@ void App::refreshCoreData()
? fast_rpc_.get() : rpc_.get(); ? fast_rpc_.get() : rpc_.get();
if (!w || !rpc) return; if (!w || !rpc) return;
ui::NavPage tracePage = current_page_; ui::NavPage tracePage = current_page_;
// Skip the balance call while syncing (it's incomplete anyway and takes the wallet lock + // Decide whether to include the balance call (z_gettotalbalance — O(mapWallet), holds cs_main).
// cs_main). Captured on the main thread to avoid reading state_ off the worker thread. // Suppress it (a) while syncing or within the post-sync settle window, so it can't starve block
const bool includeBalance = !state_.sync.syncing; // connection, and (b) unless enough time has elapsed given the LAST scan's measured cost, so a
// large shielded wallet backs off automatically instead of re-scanning every couple of seconds.
// A wallet mutation (send/shield) forces the next poll through so the user's own action updates the
// balance immediately. Captured on the main thread to avoid reading state_ off the worker thread.
const bool includeBalance = !effectivelySyncing() &&
(force_balance_refresh_ || balanceRefreshDue());
if (includeBalance) force_balance_refresh_ = false;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc, tracePage, includeBalance]() -> rpc::RPCWorker::MainCb { auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc, tracePage, includeBalance]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Core refresh")); AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Core refresh"));
@@ -1677,6 +1710,19 @@ void App::refreshCoreData()
NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr)); NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
applyPendingSendBalanceDeltas(true); applyPendingSendBalanceDeltas(true);
// Feed the adaptive balance throttle + sync-settle hysteresis. Record the last scan's
// cost (0 when balance was skipped), and arm the settle window only on the
// syncing→caught-up edge so a wallet synced from the start is never throttled at connect.
if (result.balanceScanMs > 0.0) last_balance_scan_ms_ = result.balanceScanMs;
const bool nowSyncing = state_.sync.syncing;
if (nowSyncing) {
sync_settle_until_ = 0;
} else if (was_core_syncing_) {
constexpr double kSyncSettleSeconds = 8.0;
sync_settle_until_ = std::time(nullptr) + static_cast<std::time_t>(kSyncSettleSeconds);
}
was_core_syncing_ = nowSyncing;
// Mid-session connection-loss detection. During normal operation, both core // Mid-session connection-loss detection. During normal operation, both core
// RPCs failing together means the daemon connection is dead (a busy daemon // RPCs failing together means the daemon connection is dead (a busy daemon
// fails them individually, not both at once). Warmup is excluded — both fail // fails them individually, not both at once). Warmup is excluded — both fail
@@ -4232,6 +4278,36 @@ void App::maybeRemindSeedBackup()
}); });
} }
// One-time nudge (full-node) when the BDB wallet.dat has bloated past the threshold. Berkeley DB never
// shrinks in place and shielded-note witness data accumulates, so a mining/shielded wallet can grow
// unbounded. Fires ONCE (persisted flag) a warning toast + a clickable "Consolidate notes…" entry in the
// bell/alert panel that opens Merge to Address; re-arms if the file later drops back under the threshold.
void App::maybeWarnLargeWallet()
{
if (capture_mode_ || lite_wallet_) return; // no live nags during a UI sweep; lite has no wallet.dat
if (!supportsFullNodeLifecycleActions() || !settings_) return;
if (!state_.connected || !state_.encryption_state_known) return;
if (state_.warming_up || state_.daemon_initializing || !state_.sync.isSynced()) return;
if (large_wallet_checked_) return; // stat wallet.dat at most once per launch
large_wallet_checked_ = true;
static constexpr uint64_t kWalletBloatWarnBytes = 500ull * 1024 * 1024; // 500 MB (matches the Settings banner)
const std::string walletPath = util::Platform::getDragonXDataDir() + "/wallet.dat";
const uint64_t sz = util::Platform::getFileSize(walletPath);
if (sz <= kWalletBloatWarnBytes) {
// Re-arm the one-time warning if the file shrank back under the threshold (e.g. after a fresh seed wallet).
if (settings_->getLargeWalletWarned()) { settings_->setLargeWalletWarned(false); settings_->save(); }
return;
}
if (settings_->getLargeWalletWarned()) return; // already warned once for this bloat episode
settings_->setLargeWalletWarned(true);
settings_->save();
ui::Notifications::instance().action(
TR("wallet_size_warn"), ui::NotificationType::Warning,
[]() { ui::ShieldDialog::showConsolidate(); },
TR("wallet_size_consolidate"), 12.0f);
}
// Complementary on-disk safety net (full-node) for a wallet salvage we did NOT witness this launch — it // Complementary on-disk safety net (full-node) for a wallet salvage we did NOT witness this launch — it
// happened on a prior run, or under an external daemon whose startup output we never captured, so // happened on a prior run, or under an external daemon whose startup output we never captured, so
// detectWalletAutoRecovery() never fired. If the active wallet loads EMPTY while a sibling wallet file in // detectWalletAutoRecovery() never fired. If the active wallet loads EMPTY while a sibling wallet file in
@@ -4967,21 +5043,12 @@ void App::rebuildWalletDatabase()
const std::string tmpOut = datadir + "/wallet.rebuilt-" + std::string(ts) + ".tmp"; const std::string tmpOut = datadir + "/wallet.rebuilt-" + std::string(ts) + ".tmp";
{ std::error_code ec; fs::remove(tmpOut, ec); } // helper uses DB_EXCL — path must be fresh { std::error_code ec; fs::remove(tmpOut, ec); } // helper uses DB_EXCL — path must be fresh
// 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line. // 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line. Windowless
std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\""; // (runHiddenCapture) so a wallet rebuild never flashes a cmd.exe console; it runs the
#ifdef _WIN32 // helper via CreateProcess directly on Windows, so no cmd.exe outer-quote wrap is needed.
cmd = "\"" + cmd + "\""; // cmd.exe strips the outermost quotes const std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\"";
FILE* fp = _popen(cmd.c_str(), "r"); int rc = -1;
#else const std::string jout = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/false, &rc);
FILE* fp = popen(cmd.c_str(), "r");
#endif
std::string jout;
if (fp) { char b[512]; while (std::fgets(b, sizeof b, fp)) jout += b; }
#ifdef _WIN32
const int rc = fp ? _pclose(fp) : -1;
#else
const int rc = fp ? pclose(fp) : -1;
#endif
DEBUG_LOGF("[App] wallet-rebuild helper rc=%d out=%s\n", rc, jout.c_str()); DEBUG_LOGF("[App] wallet-rebuild helper rc=%d out=%s\n", rc, jout.c_str());
// 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys. // 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys.
@@ -5269,6 +5336,7 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
// Force transaction list refresh so the sent tx appears immediately // Force transaction list refresh so the sent tx appears immediately
transactions_dirty_ = true; transactions_dirty_ = true;
last_tx_block_height_ = -1; last_tx_block_height_ = -1;
force_balance_refresh_ = true; // the user's own send must update the balance now, past the throttle
network_refresh_.markWalletMutationRefresh(); network_refresh_.markWalletMutationRefresh();
// z_sendmany only returned an opid: the transaction is built/signed/ // z_sendmany only returned an opid: the transaction is built/signed/
// broadcast asynchronously by the daemon. Defer the user-facing // broadcast asynchronously by the daemon. Defer the user-facing

View File

@@ -232,6 +232,7 @@ bool Settings::load(const std::string& path)
} }
loadScalar(j, "wizard_completed", wizard_completed_); loadScalar(j, "wizard_completed", wizard_completed_);
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_); loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
loadScalar(j, "large_wallet_warned", large_wallet_warned_);
if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) { if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) {
empty_wallet_warning_acked_.clear(); empty_wallet_warning_acked_.clear();
for (const auto& w : j["empty_wallet_warning_acked"]) for (const auto& w : j["empty_wallet_warning_acked"])
@@ -506,6 +507,7 @@ bool Settings::save(const std::string& path)
} }
j["wizard_completed"] = wizard_completed_; j["wizard_completed"] = wizard_completed_;
j["seed_backup_reminded"] = seed_backup_reminded_; j["seed_backup_reminded"] = seed_backup_reminded_;
j["large_wallet_warned"] = large_wallet_warned_;
j["empty_wallet_warning_acked"] = json::array(); j["empty_wallet_warning_acked"] = json::array();
for (const auto& w : empty_wallet_warning_acked_) for (const auto& w : empty_wallet_warning_acked_)
j["empty_wallet_warning_acked"].push_back(w); j["empty_wallet_warning_acked"].push_back(w);

View File

@@ -330,6 +330,10 @@ public:
bool getSeedBackupReminded() const { return seed_backup_reminded_; } bool getSeedBackupReminded() const { return seed_backup_reminded_; }
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; } void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
// One-time nudge when wallet.dat grows past the bloat threshold (re-armed if it shrinks back).
bool getLargeWalletWarned() const { return large_wallet_warned_; }
void setLargeWalletWarned(bool v) { large_wallet_warned_ = v; }
// Wallet filenames for which the one-time "this wallet is empty but a sibling holds funds" // Wallet filenames for which the one-time "this wallet is empty but a sibling holds funds"
// warning has been dismissed. Keyed per active wallet file so switching to a different empty // warning has been dismissed. Keyed per active wallet file so switching to a different empty
// wallet can warn again (see App::maybeWarnEmptyWalletWithFundedSiblings). // wallet can warn again (see App::maybeWarnEmptyWalletWithFundedSiblings).
@@ -597,6 +601,7 @@ private:
std::map<std::string, AddressMeta> address_meta_; std::map<std::string, AddressMeta> address_meta_;
bool wizard_completed_ = false; bool wizard_completed_ = false;
bool seed_backup_reminded_ = false; bool seed_backup_reminded_ = false;
bool large_wallet_warned_ = false;
std::set<std::string> empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed std::set<std::string> empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed
bool encryption_pending_ = false; bool encryption_pending_ = false;
long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt

View File

@@ -689,7 +689,10 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
debug_log_path_.c_str(), debug_log_offset_); debug_log_path_.c_str(), debug_log_offset_);
} }
// Launch daemon with CREATE_NEW_CONSOLE (hidden via SW_HIDE). // Launch daemon windowless. Use CREATE_NO_WINDOW (NOT CREATE_NEW_CONSOLE): CREATE_NEW_CONSOLE
// allocates a console window that briefly flashes on screen before SW_HIDE can hide it, which is
// visible as a console-window flash on wallet launch. CREATE_NO_WINDOW gives the console child no
// window at all (same approach as the xmrig launcher). The daemon logs to debug.log, not a console.
// The daemon binary must NOT be in the data directory (%APPDATA%\Hush\DRAGONX) // The daemon binary must NOT be in the data directory (%APPDATA%\Hush\DRAGONX)
// — it must be in <exe_dir>/dragonx/ to avoid conflicts with lock files and data. // — it must be in <exe_dir>/dragonx/ to avoid conflicts with lock files and data.
STARTUPINFOA si; STARTUPINFOA si;
@@ -707,7 +710,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
NULL, NULL,
NULL, NULL,
FALSE, FALSE,
CREATE_NEW_CONSOLE, CREATE_NO_WINDOW,
NULL, NULL,
work_dir.c_str(), work_dir.c_str(),
&si, &si,

View File

@@ -23,6 +23,7 @@
#include <curl/curl.h> #include <curl/curl.h>
#include "../util/logger.h" #include "../util/logger.h"
#include "../util/platform.h"
#include "../util/pool_registry.h" #include "../util/pool_registry.h"
#ifdef _WIN32 #ifdef _WIN32
@@ -145,32 +146,18 @@ std::string XmrigManager::findXmrigBinary() {
return path; return path;
} }
// Fallback: system PATH // Fallback: system PATH — windowless so it never flashes a console.
#ifdef _WIN32 #ifdef _WIN32
FILE* f = _popen("where xmrig.exe 2>nul", "r"); std::string out = util::Platform::runHiddenCapture("where xmrig.exe");
#else #else
FILE* f = popen("which xmrig 2>/dev/null", "r"); std::string out = util::Platform::runHiddenCapture("which xmrig");
#endif
if (f) {
char line[512];
if (fgets(line, sizeof(line), f)) {
std::string s(line);
while (!s.empty() && (s.back() == '\n' || s.back() == '\r'))
s.pop_back();
if (!s.empty() && fs::exists(s)) {
#ifdef _WIN32
_pclose(f);
#else
pclose(f);
#endif
return s;
}
}
#ifdef _WIN32
_pclose(f);
#else
pclose(f);
#endif #endif
{
std::string s = out;
const auto nl = s.find_first_of("\r\n"); // first line only
if (nl != std::string::npos) s.erase(nl);
while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.pop_back();
if (!s.empty() && fs::exists(s)) return s;
} }
return {}; return {};
@@ -927,24 +914,10 @@ void XmrigManager::startVersionDetection()
const bool binShellSafe = const bool binShellSafe =
!bin.empty() && bin.find_first_of("\"'`$;&|<>^%\n\r") == std::string::npos; !bin.empty() && bin.find_first_of("\"'`$;&|<>^%\n\r") == std::string::npos;
if (binShellSafe) { if (binShellSafe) {
const std::string cmd = "\"" + bin + "\" --version 2>&1"; // Windowless capture (mergeStderr: xmrig may print --version to stderr) — never flashes.
#ifdef _WIN32 const std::string cmd = "\"" + bin + "\" --version";
FILE* fp = _popen(cmd.c_str(), "r"); const std::string out = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/true);
#else if (!out.empty()) ver = parseMinerVersion(out);
FILE* fp = popen(cmd.c_str(), "r");
#endif
if (fp) {
std::string out;
char buf[256];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) out.append(buf, n);
#ifdef _WIN32
_pclose(fp);
#else
pclose(fp);
#endif
ver = parseMinerVersion(out);
}
} }
std::lock_guard<std::mutex> lk(g_installed_ver_mutex); std::lock_guard<std::mutex> lk(g_installed_ver_mutex);
g_installed_ver = ver; g_installed_ver = ver;

View File

@@ -3,6 +3,7 @@
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <chrono>
#include <cmath> #include <cmath>
#include <cstdlib> #include <cstdlib>
#include <map> #include <map>
@@ -294,8 +295,13 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
json blockInfo; json blockInfo;
bool balanceOk = false; bool balanceOk = false;
bool blockOk = false; bool blockOk = false;
double balanceScanMs = 0.0;
if (includeBalance) { if (includeBalance) {
// z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration —
// seconds on a large shielded wallet. Time it so the caller can throttle how often it polls
// (balanceRefreshDue()), keeping balance scans from starving block connection.
const auto balanceStart = std::chrono::steady_clock::now();
try { // DISPLAY total: minconf=0 — includes the user's own pending change so it doesn't crater try { // DISPLAY total: minconf=0 — includes the user's own pending change so it doesn't crater
totalBalance = rpc.call("z_gettotalbalance", json::array({0})); totalBalance = rpc.call("z_gettotalbalance", json::array({0}));
balanceOk = true; balanceOk = true;
@@ -305,6 +311,8 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
try { // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply. try { // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply.
spendableBalance = rpc.call("z_gettotalbalance", json::array({1})); spendableBalance = rpc.call("z_gettotalbalance", json::array({1}));
} catch (...) {} } catch (...) {}
balanceScanMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - balanceStart).count();
} }
try { try {
@@ -314,7 +322,9 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what()); DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
} }
return parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk); auto result = parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk);
result.balanceScanMs = balanceScanMs;
return result;
} }
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult( NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(

View File

@@ -111,6 +111,7 @@ public:
std::optional<double> verificationProgress; std::optional<double> verificationProgress;
std::optional<int> longestChain; std::optional<int> longestChain;
std::optional<int> notarized; std::optional<int> notarized;
double balanceScanMs = 0.0; // wall-clock spent in z_gettotalbalance this refresh (0 if balance skipped)
}; };
struct MiningRefreshResult { struct MiningRefreshResult {

View File

@@ -31,6 +31,8 @@ struct AlertRecord {
std::string message; std::string message;
NotificationType type; NotificationType type;
std::int64_t epoch; // std::time(nullptr) at push — wall-clock, for relative-age display std::int64_t epoch; // std::time(nullptr) at push — wall-clock, for relative-age display
std::function<void()> onClick; // optional: makes this bell-panel entry actionable
std::string actionHint; // optional: accent link label rendered for the action
}; };
struct Notification { struct Notification {
@@ -93,14 +95,24 @@ public:
push(message, NotificationType::Error, duration); push(message, NotificationType::Error, duration);
} }
void push(const std::string& message, NotificationType type, float duration = 5.0f) { // An actionable alert: a normal toast PLUS a clickable entry in the bell/alert-history panel.
// onClick fires when the user clicks the accent `actionHint` link in that panel.
void action(const std::string& message, NotificationType type, std::function<void()> onClick,
const std::string& actionHint, float duration = -1.0f) {
if (duration < 0.0f) duration = schemaDuration("duration-warning", 3.5f);
push(message, type, duration, std::move(onClick), actionHint);
}
void push(const std::string& message, NotificationType type, float duration = 5.0f,
std::function<void()> onClick = nullptr, const std::string& actionHint = "") {
notifications_.emplace_back(message, type, duration); notifications_.emplace_back(message, type, duration);
// Retain a copy in the persistent history (the toast above will fade in seconds; this // Retain a copy in the persistent history (the toast above will fade in seconds; this
// survives so the user can review what happened). Thread note: every push is on the UI // survives so the user can review what happened). Thread note: every push is on the UI
// thread (RPC results run as main-thread MainCb callbacks), so this container needs no lock, // thread (RPC results run as main-thread MainCb callbacks), so this container needs no lock,
// consistent with the rest of this class. Do NOT push from a raw worker thread. // consistent with the rest of this class. Do NOT push from a raw worker thread.
history_.push_back(AlertRecord{message, type, static_cast<std::int64_t>(std::time(nullptr))}); history_.push_back(AlertRecord{message, type, static_cast<std::int64_t>(std::time(nullptr)),
std::move(onClick), actionHint});
++total_pushed_; ++total_pushed_;
while (history_.size() > kMaxHistory) { while (history_.size() > kMaxHistory) {
history_.pop_front(); history_.pop_front();

View File

@@ -1248,7 +1248,7 @@ void RenderSettingsPage(App* app) {
ShieldDialog::show(ShieldDialog::Mode::ShieldCoinbase); ShieldDialog::show(ShieldDialog::Mode::ShieldCoinbase);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_shield_mining")); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_shield_mining"));
if (BTN("##wmerge", TR("settings_merge_to_address"), ICON_MD_CALL_MERGE)) if (BTN("##wmerge", TR("settings_merge_to_address"), ICON_MD_CALL_MERGE))
ShieldDialog::show(ShieldDialog::Mode::MergeToAddress); ShieldDialog::showMerge();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_merge")); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_merge"));
if (BTN("##wclear", TR("settings_clear_ztx"), ICON_MD_DELETE_SWEEP)) if (BTN("##wclear", TR("settings_clear_ztx"), ICON_MD_DELETE_SWEEP))
s_settingsState.confirm_clear_ztx = true; s_settingsState.confirm_clear_ztx = true;
@@ -2066,6 +2066,25 @@ void RenderSettingsPage(App* app) {
else ImGui::TextDisabled("%s", wv); else ImGui::TextDisabled("%s", wv);
} }
// Large-wallet nudge: the BDB wallet.dat bloats with shielded-note witness data and
// never shrinks in place. Past a threshold, hint the user toward consolidating notes
// (Merge to Address) to curb further growth. Full-node only (lite has no wallet.dat here).
static constexpr uint64_t kWalletBloatWarnBytes = 500ull * 1024 * 1024; // 500 MB
if (app->supportsFullNodeLifecycleActions() && wallet_size > kWalletBloatWarnBytes) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::PushStyleColor(ImGuiCol_Text, Warning());
ImGui::PushTextWrapPos(leftX + contentW);
ImGui::TextWrapped("%s", TR("wallet_size_warn"));
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_wallet_size_warn"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
if (material::ActionButton("##walletconsolidate", TR("wallet_size_consolidate"),
ICON_MD_CALL_MERGE, material::ActionTier::Secondary))
ShieldDialog::showConsolidate();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_merge"));
}
// Row 3: folder buttons (their own row so the path gets the full width). // Row 3: folder buttons (their own row so the path gets the full width).
ImGui::Dummy(ImVec2(0, Layout::spacingXs())); ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
material::ButtonFlow ff(contentW); material::ButtonFlow ff(contentW);
@@ -2543,6 +2562,7 @@ void RenderSettingsPage(App* app) {
ImGui::PushFont(body2); ImGui::PushFont(body2);
static const char* kCredits[] = { static const char* kCredits[] = {
"The Hush Developers", "The Hush Developers",
"The DragonX Developers",
"ObsidianDragon Community", "ObsidianDragon Community",
"Dear ImGui \xE2\x80\x94 Omar Cornut", "Dear ImGui \xE2\x80\x94 Omar Cornut",
"SDL3 \xE2\x80\x94 Sam Lantinga", "SDL3 \xE2\x80\x94 Sam Lantinga",

View File

@@ -5,6 +5,7 @@
#include "shield_dialog.h" #include "shield_dialog.h"
#include "../../app.h" #include "../../app.h"
#include "../../config/version.h" #include "../../config/version.h"
#include "../../data/wallet_state.h"
#include "../../rpc/rpc_client.h" #include "../../rpc/rpc_client.h"
#include "../../rpc/rpc_worker.h" #include "../../rpc/rpc_worker.h"
#include "../../util/i18n.h" #include "../../util/i18n.h"
@@ -15,39 +16,98 @@
#include <vector> #include <vector>
#include <string> #include <string>
#include <cstdio>
namespace dragonx { namespace dragonx {
namespace ui { namespace ui {
// Static state // ── Static dialog state ─────────────────────────────────────────────────────────────────────────
static bool s_open = false; static bool s_open = false;
static ShieldDialog::Mode s_mode = ShieldDialog::Mode::ShieldCoinbase; static ShieldDialog::Mode s_mode = ShieldDialog::Mode::ShieldCoinbase;
static char s_from_address[512] = "*"; static bool s_consolidate = false; // opened from the wallet-bloat nudge (shielded preset + framing)
static char s_to_address[512] = ""; 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 double s_fee = DRAGONX_DEFAULT_FEE;
static int s_utxo_limit = 50; // overridden by schema at runtime static int s_utxo_limit = 50; // overridden by schema at runtime
static bool s_operation_pending = false; 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_operation_id;
static std::string s_status_message; static std::string s_status_message;
static int s_selected_zaddr_idx = -1; static double s_last_poll = 0.0; // live-progress self-poll timer (ImGui::GetTime seconds)
// Scope of what can be consolidated (fetched once on open, merge mode only).
static bool s_scope_loading = false;
static bool s_scope_loaded = false;
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)
static void resetTransient()
{
s_operation_pending = false;
s_op_terminal = false;
s_confirm = false;
s_status_message.clear();
s_operation_id.clear();
s_creating_addr = 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.
static void loadScope(App* app)
{
if (!app || !app->worker()) return;
s_scope_loading = true; s_scope_loaded = false;
app->worker()->post([app, rpc = app->rpc()]() -> rpc::RPCWorker::MainCb {
int tC = 0, zC = 0; double tA = 0.0, zA = 0.0; std::string error;
try {
rpc::RPCClient::TraceScope trace("Shield dialog / Scope count");
nlohmann::json us = rpc->call("listunspent", nlohmann::json::array({0}));
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}));
if (zs.is_array()) for (const auto& z : zs) {
if (z.value("confirmations", 0) >= 1) { ++zC; zA += z.value("amount", 0.0); }
}
} catch (const std::exception& e) { error = e.what(); }
return [tC, zC, tA, zA, error]() {
s_scope_loading = false; s_scope_loaded = error.empty();
s_t_count = tC; s_z_count = zC; s_t_amount = tA; s_z_amount = zA;
// 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) void ShieldDialog::show(Mode mode)
{ {
s_mode = mode; s_mode = mode;
s_open = true; s_open = true;
s_operation_pending = false; s_consolidate = false; // reset preset flags so stale statics don't leak across opens
s_status_message.clear(); s_src = 2;
s_operation_id.clear(); resetTransient();
s_from_address[0] = '\0';
if (mode == Mode::ShieldCoinbase) { if (mode == Mode::ShieldCoinbase) strncpy(s_from_address, "*", sizeof(s_from_address));
strncpy(s_from_address, "*", sizeof(s_from_address));
} else {
s_from_address[0] = '\0';
}
s_to_address[0] = '\0'; s_to_address[0] = '\0';
s_selected_zaddr_idx = -1;
s_fee = DRAGONX_DEFAULT_FEE; s_fee = DRAGONX_DEFAULT_FEE;
s_utxo_limit = (int)schema::UI().drawElement("business", "utxo-limit").size; s_utxo_limit = (int)schema::UI().drawElement("business", "utxo-limit").size;
s_selected_zaddr_idx = -1; if (s_utxo_limit < 1) s_utxo_limit = 50;
s_advanced = false;
s_scope_loaded = false; s_scope_loading = false;
s_t_count = s_z_count = 0; s_t_amount = s_z_amount = 0.0;
s_last_poll = 0.0;
} }
void ShieldDialog::showShieldCoinbase(const std::string& fromAddress) void ShieldDialog::showShieldCoinbase(const std::string& fromAddress)
@@ -59,14 +119,146 @@ void ShieldDialog::showShieldCoinbase(const std::string& fromAddress)
void ShieldDialog::showMerge() void ShieldDialog::showMerge()
{ {
show(Mode::MergeToAddress); 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)
} }
void ShieldDialog::hide() void ShieldDialog::hide()
{ {
s_open = false; s_open = false;
s_operation_pending = false; resetTransient();
s_status_message.clear(); }
s_operation_id.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; }
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;
try {
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});
} 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("merge_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("merge_send_failed")) + error);
}
};
});
}
// 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 for
// balance refresh; this drives only the inline display.)
static void pollOperation(App* app)
{
if (s_operation_id.empty() || s_op_terminal || !app->worker()) return;
const double now = ImGui::GetTime();
if (now - s_last_poll < 2.0) return;
s_last_poll = now;
std::string opid = s_operation_id;
app->worker()->post([rpc = app->rpc(), opid]() -> rpc::RPCWorker::MainCb {
nlohmann::json result; std::string error;
try {
rpc::RPCClient::TraceScope trace("Shield dialog / Op status");
result = rpc->call("z_getoperationstatus", {nlohmann::json::array({opid})});
} catch (const std::exception& e) { error = e.what(); }
return [result, error]() {
if (!error.empty() || !result.is_array() || result.empty()) return; // transient — retry next tick
const auto& op = result[0];
const std::string status = op.value("status", "");
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") {
std::string msg = op.value("error", nlohmann::json{}).value("message", std::string(TR("shield_unknown_error")));
s_operation_pending = false; s_op_terminal = true;
s_status_message = std::string(TR("shield_op_failed")) + msg;
Notifications::instance().error(std::string(TR("shield_op_failed")) + msg);
}
// queued / executing → leave the "Consolidating…" message and keep polling.
};
});
} }
void ShieldDialog::render(App* app) void ShieldDialog::render(App* app)
@@ -74,263 +266,221 @@ void ShieldDialog::render(App* app)
if (!s_open) return; if (!s_open) return;
auto& S = schema::UI(); auto& S = schema::UI();
auto win = S.window("dialogs.shield"); auto win = S.window("dialogs.shield");
auto addrLbl = S.label("dialogs.shield", "address-label"); auto addrLbl = S.label("dialogs.shield", "address-label");
auto addrFrontLbl = S.label("dialogs.shield", "address-front-label"); auto addrFront = S.label("dialogs.shield", "address-front-label");
auto addrBackLbl = S.label("dialogs.shield", "address-back-label"); auto addrBack = S.label("dialogs.shield", "address-back-label");
auto feeInput = S.input("dialogs.shield", "fee-input"); auto feeInput = S.input("dialogs.shield", "fee-input");
auto utxoInput = S.input("dialogs.shield", "utxo-limit-input"); auto utxoInput = S.input("dialogs.shield", "utxo-limit-input");
auto shieldBtn = S.button("dialogs.shield", "shield-button"); auto shieldBtn = S.button("dialogs.shield", "shield-button");
auto cancelBtn = S.button("dialogs.shield", "cancel-button"); auto cancelBtn = S.button("dialogs.shield", "cancel-button");
const float dp = Layout::dpiScale();
const bool isMerge = (s_mode == Mode::MergeToAddress);
const char* title = (s_mode == Mode::ShieldCoinbase) const char* title = s_consolidate ? TR("consolidate_title")
? TR("shield_title") : isMerge ? TR("merge_title")
: TR("merge_title"); : TR("shield_title");
material::OverlayDialogSpec ov; material::OverlayDialogSpec ov;
ov.title = title; ov.p_open = &s_open; ov.title = title; ov.p_open = &s_open;
ov.style = material::OverlayStyle::BlurFloat; ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = win.width; ov.idSuffix = "shielddialog"; ov.cardWidth = win.width; ov.idSuffix = "shielddialog";
if (material::BeginOverlayDialog(ov)) { if (!material::BeginOverlayDialog(ov)) return;
const auto& state = app->getWalletState();
// Description const auto& state = app->getWalletState();
if (s_mode == Mode::ShieldCoinbase) { autoSelectDestination(state);
ImGui::TextWrapped("%s", TR("shield_description")); pollOperation(app);
} else { if (isMerge && !s_scope_loaded && !s_scope_loading && s_operation_id.empty()) loadScope(app);
ImGui::TextWrapped("%s", TR("merge_description"));
// ── Description ──────────────────────────────────────────────────────────────────────────────
ImGui::TextWrapped("%s", s_consolidate ? TR("consolidate_desc")
: isMerge ? TR("merge_description")
: TR("shield_description"));
ImGui::Spacing();
const bool opInFlight = !s_operation_id.empty(); // submitted — inputs frozen, showing progress
// ── Merge: scope + source selector ──────────────────────────────────────────────────────────
if (isMerge && !opInFlight) {
if (s_scope_loading) {
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(),
TR("merge_scope_loading"));
} else if (s_scope_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);
} }
ImGui::Spacing(); ImGui::Spacing();
// From address (for shield coinbase) // Source selector — only offer the types that actually have inputs.
if (s_mode == Mode::ShieldCoinbase) { const bool tOk = s_t_count > 0, zOk = s_z_count > 0;
material::LabeledInput(TR("shield_from_address"), "##FromAddr", s_from_address, sizeof(s_from_address)); if (tOk && zOk) {
ImGui::TextDisabled("%s", TR("shield_wildcard_hint")); 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(); ImGui::Spacing();
} }
// To address (z-address dropdown)
ImGui::Text("%s", TR("shield_to_address"));
// Get z-addresses for dropdown
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, addrFrontLbl.truncate) + "..." + to_display.substr(to_display.length() - addrBackLbl.truncate);
}
ImGui::SetNextItemWidth(-1);
if (ImGui::BeginCombo("##ToAddr", to_display.c_str())) {
for (size_t i = 0; i < state.z_addresses.size(); i++) {
const auto& addr = state.z_addresses[i];
std::string label = addr.address;
if (label.length() > static_cast<size_t>(addrLbl.truncate)) {
label = label.substr(0, addrFrontLbl.truncate) + "..." + label.substr(label.length() - addrBackLbl.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, addr.address.c_str(), sizeof(s_to_address) - 1);
}
if (selected) {
ImGui::SetItemDefaultFocus();
}
}
ImGui::EndCombo();
}
if (state.z_addresses.empty()) {
material::Type().textColored(material::TypeStyle::Caption, material::Warning(),
TR("shield_no_zaddr_hint"));
}
ImGui::Spacing();
// Fee + UTXO limit share one row (two columns) to tighten vertical rhythm.
float pairColX = ImGui::GetContentRegionAvail().x * 0.5f;
// Fee (left column)
ImGui::Text("%s", TR("fee_label"));
ImGui::SetNextItemWidth(feeInput.width * Layout::dpiScale());
ImGui::InputDouble("##Fee", &s_fee, 0.0001, 0.001, "%.8f");
if (s_fee < 0.0) s_fee = 0.0; // no negative fee
if (s_fee > 1.0) s_fee = 1.0; // guard a fat-fingered huge fee (mirrors utxo clamp)
ImGui::SameLine();
ImGui::TextDisabled("DRGX");
// UTXO limit (right column) — hint drops under the input (rather than beside it) since
// "Max UTXOs per operation" is too long to share the narrower half-width column with "DRGX".
ImGui::SameLine(pairColX);
ImGui::BeginGroup();
ImGui::Text("%s", TR("shield_utxo_limit"));
ImGui::SetNextItemWidth(utxoInput.width * Layout::dpiScale());
ImGui::InputInt("##Limit", &s_utxo_limit);
if (s_utxo_limit < 1) s_utxo_limit = 1;
if (s_utxo_limit > 100) s_utxo_limit = 100;
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(),
TR("shield_max_utxos"));
ImGui::EndGroup();
ImGui::Spacing();
// Status message
if (!s_status_message.empty()) {
if (s_operation_pending) {
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();
}
// Buttons — guard on connection/sync like the Send tab (a disconnected or mid-sync submit just
// fails at the daemon with a raw error).
bool sh_connected = app->isConnected();
bool sh_syncing = state.sync.syncing;
bool can_submit = !s_operation_pending && s_to_address[0] != '\0' && sh_connected && !sh_syncing;
// Center the primary + Cancel action row via the shared footer helper. We can't use
// DialogActionFooter here because the primary button carries a disabled-hover tooltip that must
// fire on ITS item (the helper draws primary+Close internally, leaving no hook between them), so
// we keep the two TactileButtons + the interleaved tooltip and only standardize the placement.
const char* btn_label = (s_mode == Mode::ShieldCoinbase) ? TR("shield_funds") : TR("merge_funds");
float footerBtnW = shieldBtn.width + cancelBtn.width + ImGui::GetStyle().ItemSpacing.x;
material::BeginOverlayDialogFooter(footerBtnW, /*drawSeparator=*/false);
if (!can_submit) ImGui::BeginDisabled();
if (material::TactileButton(btn_label, ImVec2(shieldBtn.width, 0), S.resolveFont(shieldBtn.font))) {
s_operation_pending = true;
s_status_message = TR("shield_submitting");
if (s_mode == Mode::ShieldCoinbase) {
std::string from(s_from_address), to(s_to_address);
double fee = s_fee;
int limit = s_utxo_limit;
if (app->worker()) {
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("Send tab / Shield coinbase");
result = rpc->call("z_shieldcoinbase", {from, to, fee, limit});
} catch (const std::exception& e) {
error = e.what();
}
return [app, result, error]() {
s_operation_pending = false;
if (error.empty()) {
s_operation_id = result.value("opid", "");
s_status_message = std::string(TR("shield_op_submitted")) + s_operation_id;
Notifications::instance().success(TR("shield_started"));
// Register with the shared poller so an async failure is
// surfaced (and balances refresh) even after this dialog closes.
app->trackOperation(s_operation_id);
} else {
s_status_message = std::string(TR("shield_error_prefix")) + error;
Notifications::instance().error(std::string(TR("shield_send_failed")) + error);
}
};
});
}
} else {
std::vector<std::string> fromAddrs;
fromAddrs.push_back("ANY_TADDR");
std::string to(s_to_address);
double fee = s_fee;
int limit = s_utxo_limit;
if (app->worker()) {
app->worker()->post([app, rpc = app->rpc(), fromAddrs, to, fee, limit]() -> rpc::RPCWorker::MainCb {
nlohmann::json addrs = nlohmann::json::array();
for (const auto& addr : fromAddrs) addrs.push_back(addr);
nlohmann::json result;
std::string error;
try {
rpc::RPCClient::TraceScope trace("Send tab / Merge funds");
result = rpc->call("z_mergetoaddress", {addrs, to, fee, 0, limit});
} catch (const std::exception& e) {
error = e.what();
}
return [app, result, error]() {
s_operation_pending = false;
if (error.empty()) {
s_operation_id = result.value("opid", "");
s_status_message = std::string(TR("shield_op_submitted")) + s_operation_id;
Notifications::instance().success(TR("merge_started"));
// Register with the shared poller so an async failure is
// surfaced (and balances refresh) even after this dialog closes.
app->trackOperation(s_operation_id);
} else {
s_status_message = std::string(TR("shield_error_prefix")) + error;
Notifications::instance().error(std::string(TR("merge_send_failed")) + error);
}
};
});
}
}
}
if (!can_submit) ImGui::EndDisabled();
if (!can_submit && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
if (!sh_connected) material::Tooltip("%s", TR("send_tooltip_not_connected"));
else if (sh_syncing) material::Tooltip("%s", TR("send_tooltip_syncing"));
else if (s_to_address[0]=='\0') material::Tooltip("%s", TR("shield_select_z"));
}
ImGui::SameLine();
if (material::TactileButton(TR("cancel"), ImVec2(cancelBtn.width, 0), S.resolveFont(cancelBtn.font))) {
s_open = false;
}
// Show operation status if we have an opid
if (!s_operation_id.empty()) {
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
ImGui::Text(TR("shield_operation_id"), s_operation_id.c_str());
if (material::TactileButton(TR("shield_check_status"), ImVec2(0,0), S.resolveFont(shieldBtn.font))) {
std::string opid = s_operation_id;
if (app->worker()) {
app->worker()->post([rpc = app->rpc(), opid]() -> rpc::RPCWorker::MainCb {
nlohmann::json result;
std::string error;
try {
rpc::RPCClient::TraceScope trace("Send tab / Shield operation status");
nlohmann::json ids = nlohmann::json::array();
ids.push_back(opid);
result = rpc->call("z_getoperationstatus", {ids});
} catch (const std::exception& e) {
error = e.what();
}
return [result, error]() {
if (error.empty() && result.is_array() && !result.empty()) {
auto& op = result[0];
std::string status = op.value("status", "unknown");
if (status == "success") {
s_status_message = TR("shield_completed");
Notifications::instance().success(TR("shield_merge_done"));
} else if (status == "failed") {
std::string errMsg = op.value("error", nlohmann::json{}).value("message", TR("shield_unknown_error"));
s_status_message = std::string(TR("shield_op_failed")) + errMsg;
Notifications::instance().error(std::string(TR("shield_op_failed")) + errMsg);
} else if (status == "executing") {
s_status_message = TR("shield_in_progress");
} else {
s_status_message = std::string(TR("shield_status_label")) + status;
}
} else if (!error.empty()) {
s_status_message = std::string(TR("shield_status_check_error")) + error;
}
};
});
}
}
}
material::EndOverlayDialog();
} }
// ── 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) {
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();
// ── 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 (isMerge && s_scope_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();
}
// ── 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 && 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;
if (isMerge && s_scope_loaded && srcCount() == 0) can_submit = false;
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) { 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 (!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 ui

View File

@@ -33,10 +33,16 @@ public:
static void showShieldCoinbase(const std::string& fromAddress = "*"); static void showShieldCoinbase(const std::string& fromAddress = "*");
/** /**
* @brief Show merge to address dialog * @brief Show merge to address dialog (generic — both transparent + shielded sources)
*/ */
static void showMerge(); static void showMerge();
/**
* @brief Show the consolidate-funds flow preset for wallet-bloat reduction (shielded notes).
* Used by the large-wallet nudges (Settings banner + alert action).
*/
static void showConsolidate();
/** /**
* @brief Render the dialog (call each frame) * @brief Render the dialog (call each frame)
*/ */

View File

@@ -667,6 +667,9 @@ void I18n::loadBuiltinEnglish()
strings_["wiz_pin_mismatch"] = "PINs do not match"; strings_["wiz_pin_mismatch"] = "PINs do not match";
strings_["settings_data_dir"] = "Data Dir"; strings_["settings_data_dir"] = "Data Dir";
strings_["settings_wallet_size_label"] = "Wallet Size"; strings_["settings_wallet_size_label"] = "Wallet Size";
strings_["wallet_size_warn"] = "This wallet file is large. Consolidating your notes can curb further growth.";
strings_["tt_wallet_size_warn"] = "Shielded wallets grow with each note's witness data — merging many notes into one address reduces it. Back up first.";
strings_["wallet_size_consolidate"] = "Consolidate notes\xE2\x80\xA6";
strings_["settings_debug_changed"] = "Debug categories changed \xe2\x80\x94 restart daemon to apply"; strings_["settings_debug_changed"] = "Debug categories changed \xe2\x80\x94 restart daemon to apply";
strings_["settings_auto_detected"] = "Auto-detected from DRAGONX.conf"; strings_["settings_auto_detected"] = "Auto-detected from DRAGONX.conf";
strings_["settings_visual_effects"] = "Visual Effects"; strings_["settings_visual_effects"] = "Visual Effects";
@@ -1453,8 +1456,8 @@ void I18n::loadBuiltinEnglish()
strings_["sb_daemon_extract_failed"] = "Failed to write daemon files — check free disk space and permissions."; strings_["sb_daemon_extract_failed"] = "Failed to write daemon files — check free disk space and permissions.";
strings_["sb_daemon_files_failed"] = "Failed to write daemon files to %s — check free disk space and permissions."; strings_["sb_daemon_files_failed"] = "Failed to write daemon files to %s — check free disk space and permissions.";
strings_["loading_stall_title"] = "Taking longer than expected"; strings_["loading_stall_title"] = "Taking longer than expected";
strings_["loading_stall_body"] = "The daemon has been initializing for %.0fs. This can be normal after an update or on first launch (loading the block index or rescanning) — it will connect automatically once ready."; strings_["loading_stall_body"] = "Initializing for %.0fs normal after an update or first launch. Connects automatically when ready.";
strings_["loading_stall_hint"] = "Still stuck? Open Settings and use Restart Daemon, or check the Console for details."; strings_["loading_stall_hint"] = "Stuck? Settings Restart Daemon, or check the Console.";
strings_["sb_plaintext_remote_blocked"] = "Refusing to send RPC credentials over plaintext to a remote host. Add rpcallowplaintext=1 to DRAGONX.conf to allow it, or enable TLS with rpctls=1."; strings_["sb_plaintext_remote_blocked"] = "Refusing to send RPC credentials over plaintext to a remote host. Add rpcallowplaintext=1 to DRAGONX.conf to allow it, or enable TLS with rpctls=1.";
strings_["rpc_plaintext_remote_warning"] = "Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS."; strings_["rpc_plaintext_remote_warning"] = "Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS.";
strings_["settings_open_log_folder"] = "Open log folder"; strings_["settings_open_log_folder"] = "Open log folder";
@@ -2238,6 +2241,29 @@ void I18n::loadBuiltinEnglish()
strings_["merge_funds"] = "Merge Funds"; strings_["merge_funds"] = "Merge Funds";
strings_["merge_started"] = "Merge operation started"; strings_["merge_started"] = "Merge operation started";
strings_["merge_title"] = "Merge to Address"; strings_["merge_title"] = "Merge to Address";
// Consolidate-funds flow (rich merge modal + wallet-bloat preset).
strings_["consolidate_title"] = "Consolidate funds";
strings_["consolidate_desc"] = "Combine many small inputs into a single shielded note. Fewer notes means a smaller wallet file and better privacy.";
strings_["consolidate_funds_btn"] = "Consolidate";
strings_["merge_scope_loading"] = "Checking your inputs\xE2\x80\xA6";
strings_["merge_scope_fmt"] = "%d transparent + %d shielded inputs \xC2\xB7 ~%s DRGX spendable";
strings_["merge_source"] = "Consolidate";
strings_["merge_src_transparent"] = "Transparent";
strings_["merge_src_shielded"] = "Shielded";
strings_["merge_src_both"] = "Both";
strings_["merge_batch_fmt"] = "Merges up to %d inputs per run \xE2\x80\x94 repeat to finish the rest.";
strings_["merge_advanced"] = "Advanced";
strings_["merge_max_inputs"] = "Max inputs per batch";
strings_["merge_fee_hint"] = "Network fee for this transaction.";
strings_["merge_create_zaddr"] = "Create shielded address";
strings_["merge_creating"] = "Creating address\xE2\x80\xA6";
strings_["merge_addr_created"] = "Shielded address created.";
strings_["merge_confirm_fmt"] = "Consolidate ~%s DRGX from %d input(s) into %s?";
strings_["merge_confirm_btn"] = "Confirm";
strings_["merge_back"] = "Back";
strings_["merge_progress"] = "Consolidating\xE2\x80\xA6 this can take a few minutes. You can close this window.";
strings_["merge_no_spendable"] = "No spendable inputs to consolidate yet.";
strings_["done"] = "Done";
// --- Transaction Details Dialog --- // --- Transaction Details Dialog ---
strings_["tx_confirmations"] = "%d confirmations"; strings_["tx_confirmations"] = "%d confirmations";

View File

@@ -867,6 +867,70 @@ int Platform::getSystemIdleSeconds()
// GPU utilization detection // GPU utilization detection
// ============================================================================ // ============================================================================
std::string Platform::runHiddenCapture(const std::string& cmdLine, bool mergeStderr, int* exitCode)
{
if (exitCode) *exitCode = -1;
#ifdef _WIN32
SECURITY_ATTRIBUTES sa;
ZeroMemory(&sa, sizeof(sa));
sa.nLength = sizeof(sa);
sa.bInheritHandle = TRUE;
HANDLE hRead = NULL, hWrite = NULL;
if (!CreatePipe(&hRead, &hWrite, &sa, 0)) return {};
SetHandleInformation(hRead, HANDLE_FLAG_INHERIT, 0); // parent's read end stays private
HANDLE hNul = INVALID_HANDLE_VALUE;
if (!mergeStderr) {
hNul = CreateFileA("NUL", GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, &sa,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
}
STARTUPINFOA si;
ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
si.wShowWindow = SW_HIDE;
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
si.hStdOutput = hWrite;
si.hStdError = mergeStderr ? hWrite : hNul;
PROCESS_INFORMATION pi;
ZeroMemory(&pi, sizeof(pi));
std::string cl = cmdLine; // CreateProcessA may modify lpCommandLine → needs a mutable buffer
std::string out;
if (CreateProcessA(NULL, cl.empty() ? NULL : &cl[0], NULL, NULL, TRUE,
CREATE_NO_WINDOW, NULL, NULL, &si, &pi)) {
CloseHandle(hWrite); hWrite = NULL; // close our copy so ReadFile hits EOF when the child exits
if (hNul != INVALID_HANDLE_VALUE) { CloseHandle(hNul); hNul = INVALID_HANDLE_VALUE; }
char buf[4096];
DWORD n = 0;
while (ReadFile(hRead, buf, sizeof(buf), &n, NULL) && n > 0) out.append(buf, n);
WaitForSingleObject(pi.hProcess, INFINITE);
if (exitCode) {
DWORD code = 0;
if (GetExitCodeProcess(pi.hProcess, &code)) *exitCode = static_cast<int>(code);
}
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
}
if (hWrite != NULL) CloseHandle(hWrite);
if (hNul != INVALID_HANDLE_VALUE) CloseHandle(hNul);
CloseHandle(hRead);
return out;
#else
const std::string full = cmdLine + (mergeStderr ? " 2>&1" : " 2>/dev/null");
std::string out;
FILE* f = popen(full.c_str(), "r");
if (!f) return out;
char buf[512];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), f)) > 0) out.append(buf, n);
const int st = pclose(f);
if (exitCode) *exitCode = st; // raw status (matches prior pclose-based rc checks)
return out;
#endif
}
int Platform::getGpuUtilization() int Platform::getGpuUtilization()
{ {
#ifdef _WIN32 #ifdef _WIN32
@@ -877,23 +941,16 @@ int Platform::getGpuUtilization()
static bool s_has_nvidia = false; static bool s_has_nvidia = false;
if (!s_tried_nvidia) { if (!s_tried_nvidia) {
s_tried_nvidia = true; s_tried_nvidia = true;
FILE* f = _popen("where nvidia-smi 2>nul", "r"); // Windowless (runHiddenCapture) so GPU-aware idle detection never flashes a cmd.exe console.
if (f) { const std::string w = runHiddenCapture("where nvidia-smi");
char buf[256]; s_has_nvidia = (w.find_first_not_of(" \t\r\n") != std::string::npos);
s_has_nvidia = (fgets(buf, sizeof(buf), f) != nullptr);
_pclose(f);
}
} }
if (s_has_nvidia) { if (s_has_nvidia) {
FILE* f = _popen("nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits 2>nul", "r"); const std::string o = runHiddenCapture(
if (f) { "nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits");
char buf[64]; if (!o.empty()) {
int util = -1; int util = atoi(o.c_str());
if (fgets(buf, sizeof(buf), f)) { if (util < 0 || util > 100) util = -1;
util = atoi(buf);
if (util < 0 || util > 100) util = -1;
}
_pclose(f);
return util; return util;
} }
} }

View File

@@ -179,6 +179,15 @@ public:
* @return GPU busy percent, or -1 if unavailable. * @return GPU busy percent, or -1 if unavailable.
*/ */
static int getGpuUtilization(); static int getGpuUtilization();
// Run a command line and capture its stdout WITHOUT ever popping a console window: Windows uses
// CreateProcess + CREATE_NO_WINDOW (a plain popen()/_popen() flashes a cmd.exe console), POSIX uses
// popen(). Use this instead of _popen for anything run while the GUI is up. `mergeStderr` folds the
// child's stderr into the result (like "2>&1"); otherwise stderr is discarded. `exitCode`, if given,
// receives the child's exit status (raw pclose() status on POSIX, GetExitCodeProcess on Windows; -1
// if the process could not be launched).
static std::string runHiddenCapture(const std::string& cmdLine, bool mergeStderr = false,
int* exitCode = nullptr);
}; };
/** /**