From 36efe99a0f74facf192571ef01ab21009041b663 Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 11 Jul 2026 12:47:27 -0500 Subject: [PATCH] feat(wallets): vertical label+icon badge stack + async probing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the wallet.dat status badges as a right-aligned vertical stack of "label icon" rows ("Seed phrase ๐ŸŒฑ" over "Encrypted ๐Ÿ”’", "Legacy", or "Unknown"), each with a hover tooltip; name/metadata reserve the stack width and truncate before it. Move the wallet.dat probing off the UI thread: scan() now builds the row list synchronously (filename/size only) and hands the file reads to a detached background thread whose results land in a shared, index-aligned batch that render() reads under a mutex. A re-scan supersedes the old batch (cancel + swap); the thread touches only its own heap batch (never s_rows/statics), so it's safe across re-scans and shutdown. The dialog opens instantly even with a large active wallet; badges fill in over the next few frames. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/windows/wallets_dialog.h | 153 ++++++++++++++++++++------------ src/util/i18n.cpp | 4 + 2 files changed, 102 insertions(+), 55 deletions(-) diff --git a/src/ui/windows/wallets_dialog.h b/src/ui/windows/wallets_dialog.h index 439d6ba..4a4276a 100644 --- a/src/ui/windows/wallets_dialog.h +++ b/src/ui/windows/wallets_dialog.h @@ -10,11 +10,15 @@ #pragma once #include +#include #include #include #include #include +#include +#include #include +#include #include #include "imgui.h" @@ -171,24 +175,36 @@ public: const float btnX = rMax.x - padX - btnW; const float textR = btnX - Layout::spacingMd(); - // Name (+ info icon for external files, + status badges from the no-daemon file probe) + // Name (+ info icon for external files). Status badges render as a right-aligned vertical + // stack of "label icon" rows (e.g. "Seed phrase ๐ŸŒฑ" over "Encrypted ๐Ÿ”’") to the left of the + // action button โ€” reserve their width first so name/metadata truncate before them. const float topY = rMin.y + cardPadY; - // Right-aligned status badges: a lock (if encrypted) and a seed/legacy marker. Reserve their - // width up front so the filename truncates before them instead of overlapping. const float badgeSz = metaFont->LegacySize; - // Only positive detections are definitive; a MISSING marker is trustworthy solely when the - // whole file was scanned (r.probeComplete). So: lock iff encrypted; seed iff hdSeed; "legacy" - // only when complete AND no seed; and when the scan was truncated without confirming - // encryption, an explicit "unknown" badge โ€” so absence-of-lock never falsely reads as - // "unencrypted" on a huge wallet whose mkey record sat past the probe cap. - const bool bLock = r.probed && r.encrypted; - const bool bSeed = r.probed && r.hdSeed; - const bool bLegacy = r.probed && r.probeComplete && !r.hdSeed; - const bool bUnknown = r.probed && !r.probeComplete && !r.encrypted; - const int nBadges = (bLock ? 1 : 0) + (bSeed ? 1 : 0) + (bLegacy ? 1 : 0) + (bUnknown ? 1 : 0); - const float badgeReserve = nBadges > 0 ? nBadges * (badgeSz + Layout::spacingSm()) + Layout::spacingSm() : 0.0f; - float extra = ((!r.inDatadir) ? (metaFont->LegacySize + Layout::spacingSm()) : 0.0f) + badgeReserve; - std::string nm = fit(r.fileName, nameFont, std::max(24.0f * dp, textR - x - extra)); + // Positive detections are definitive; a MISSING marker is trustworthy only when the whole + // file was scanned (probeComplete): seed iff hdSeed; "legacy" only when complete & no seed; + // "encrypted" iff mkey found; and an "unknown" row when a truncated scan couldn't confirm + // encryption โ€” so absence of a lock never falsely reads as "unencrypted" on a huge wallet. + ProbeResult pres; // filled in asynchronously by the background probe thread + if (s_probe) { std::lock_guard lk(s_probe->mtx); + if (i < s_probe->results.size()) pres = s_probe->results[i]; } + const bool bLock = pres.probed && pres.encrypted; + const bool bSeed = pres.probed && pres.hdSeed; + const bool bLegacy = pres.probed && pres.complete && !pres.hdSeed; + const bool bUnknown = pres.probed && !pres.complete && !pres.encrypted; + struct Badge { const char* glyph; ImU32 col; const char* label; const char* tip; }; + Badge bl[4]; int nb = 0; + if (bSeed) bl[nb++] = { ICON_MD_ECO, WithAlpha(Success(), 235), TR("wallets_badge_seed_short"), TR("wallets_badge_seed") }; + if (bLock) bl[nb++] = { ICON_MD_LOCK, WithAlpha(Warning(), 240), TR("wallets_badge_encrypted_short"), TR("wallets_badge_encrypted") }; + if (bLegacy) bl[nb++] = { ICON_MD_HISTORY, WithAlpha(OnSurfaceMedium(), 220), TR("wallets_badge_legacy_short"), TR("wallets_badge_legacy") }; + if (bUnknown) bl[nb++] = { ICON_MD_HELP_OUTLINE, WithAlpha(OnSurfaceMedium(), 185), TR("wallets_badge_unknown_short"), TR("wallets_badge_unknown") }; + const float bGap = Layout::spacingSm(); + float maxLabelW = 0.0f; + for (int k = 0; k < nb; ++k) maxLabelW = std::max(maxLabelW, tw(metaFont, bl[k].label)); + const float stackW = nb > 0 ? maxLabelW + bGap + badgeSz : 0.0f; + const float stackLeft = nb > 0 ? textR - stackW - Layout::spacingMd() : textR; // text stops here + const float infoReserve = (!r.inDatadir) ? (metaFont->LegacySize + Layout::spacingSm()) : 0.0f; + + std::string nm = fit(r.fileName, nameFont, std::max(24.0f * dp, stackLeft - x - infoReserve)); dl->AddText(nameFont, nameFont->LegacySize, ImVec2(x, topY), OnSurface(), nm.c_str()); if (!r.inDatadir) { ImVec2 ip(x + tw(nameFont, nm) + Layout::spacingSm(), topY + (nameFont->LegacySize - metaFont->LegacySize)); @@ -196,19 +212,19 @@ public: if (ImGui::IsMouseHoveringRect(ip, ImVec2(ip.x + metaFont->LegacySize, ip.y + metaFont->LegacySize))) Tooltip("%s\n%s", TR("wallets_external_tt"), r.dir.c_str()); } - if (nBadges > 0) { - float bx = textR; - auto badge = [&](const char* glyph, ImU32 col, const char* tip) { - bx -= badgeSz; - ImVec2 bp(bx, topY + (nameFont->LegacySize - badgeSz)); - dl->AddText(icoFont, badgeSz, bp, col, glyph); - if (ImGui::IsMouseHoveringRect(bp, ImVec2(bp.x + badgeSz, bp.y + badgeSz))) Tooltip("%s", tip); - bx -= Layout::spacingSm(); - }; - if (bLock) badge(ICON_MD_LOCK, WithAlpha(Warning(), 235), TR("wallets_badge_encrypted")); - if (bSeed) badge(ICON_MD_ECO, WithAlpha(Success(), 220), TR("wallets_badge_seed")); - if (bLegacy) badge(ICON_MD_HISTORY, WithAlpha(OnSurfaceMedium(), 210), TR("wallets_badge_legacy")); - if (bUnknown) badge(ICON_MD_HELP_OUTLINE, WithAlpha(OnSurfaceMedium(), 170), TR("wallets_badge_unknown")); + if (nb > 0) { + const float lineH = metaFont->LegacySize; + const float vGap = Layout::spacingXs(); + const float totalH = nb * lineH + (nb - 1) * vGap; + const float sy = midY - totalH * 0.5f; + for (int k = 0; k < nb; ++k) { + const float ey = sy + k * (lineH + vGap); + const float lw = tw(metaFont, bl[k].label); + dl->AddText(metaFont, lineH, ImVec2(textR - badgeSz - bGap - lw, ey), bl[k].col, bl[k].label); + dl->AddText(icoFont, badgeSz, ImVec2(textR - badgeSz, ey), bl[k].col, bl[k].glyph); + if (ImGui::IsMouseHoveringRect(ImVec2(textR - badgeSz - bGap - lw, ey), ImVec2(textR, ey + lineH))) + Tooltip("%s", bl[k].tip); + } } // Metadata line (size ยท N addresses ยท balance DRGX ยท last opened) @@ -218,7 +234,7 @@ public: if (meta && meta->cachedBalance >= 0.0) { snprintf(b, sizeof(b), "%s%.4f %s", dot, meta->cachedBalance, DRAGONX_TICKER); ms += b; } ms += dot; ms += (meta && meta->lastOpenedEpoch > 0) ? util::formatTimeAgo(meta->lastOpenedEpoch) : std::string(TR("wallets_never")); - ms = fit(ms, metaFont, std::max(24.0f * dp, textR - x)); + ms = fit(ms, metaFont, std::max(24.0f * dp, stackLeft - x)); dl->AddText(metaFont, metaFont->LegacySize, ImVec2(x, topY + nameFont->LegacySize + Layout::spacingXs()), OnSurfaceMedium(), ms.c_str()); @@ -323,11 +339,22 @@ private: std::string dir; bool inDatadir = false; long long sizeBytes = 0; - // Light facts read off the wallet.dat file WITHOUT loading it (see util/wallet_file_probe.h). - bool probed = false; // the file validated as a BDB wallet and was scanned - bool probeComplete = false; // scan covered the whole file โ†’ a MISSING marker is trustworthy - bool encrypted = false; // passphrase-encrypted (has an mkey record) - bool hdSeed = false; // HD/seed wallet (else legacy) โ€” ties into migrate-to-seed + }; + + // Light facts read off each wallet.dat WITHOUT loading it (util/wallet_file_probe.h), computed on a + // background thread so scan() never blocks the UI on a big file read. Results live in a shared, + // index-aligned batch: the probe thread fills it, render() reads it under the mutex, and a re-scan + // supersedes the old batch (cancel + swap) so a detached in-flight probe can't touch stale rows. + struct ProbeResult { + bool probed = false; // validated as a BDB wallet and scanned + bool complete = false; // scan covered the whole file โ†’ a MISSING marker is trustworthy + bool encrypted = false; // has an mkey record + bool hdSeed = false; // HD/seed wallet (else legacy) + }; + struct ProbeBatch { + std::mutex mtx; + std::vector results; // index-aligned with s_rows at the moment of scan() + std::atomic cancel{false}; }; // Force a "wallet-...dat" filename (plain, no path) so a new/imported wallet shows in the @@ -381,6 +408,9 @@ private: // Exception-safe: iterates with error_codes. Recursion is bounded (depth / hit / visit caps) and // never follows directory symlinks (std default), so it can't loop or stall the UI on a huge tree. static void scan() { + // Supersede any in-flight probe batch: its detached thread notices cancel and stops. It only ever + // touches its OWN batch (never s_rows), so rebuilding the list below is safe while it winds down. + if (s_probe) s_probe->cancel.store(true); s_rows.clear(); namespace fs = std::filesystem; // Guards for recursive external-folder scans (a user could point us at a large tree / home dir). @@ -388,14 +418,6 @@ private: constexpr size_t kMaxHits = 200; // cap total wallet files listed constexpr int kMaxVisited = 40000; // cap total entries walked, so scan() can't stall the UI - // Shared budget for the no-daemon wallet.dat probes (encryption / seed badges) so a pile of large - // wallets can't turn scan() into a multi-hundred-MB read on the UI thread; each file is capped too. - // Cover realistic wallets fully (the active wallet can be well over 100 MB of tx history, and a - // BDB btree sorts long-keyed records โ€” mkey/hdchain โ€” LATE, so a small cap risks a false-negative - // badge). Per-file 256 MB + a shared budget keep even that a bounded, one-shot read on dialog open. - std::size_t probeBudget = 768u * 1024u * 1024u; - constexpr std::size_t kProbePerFile = 256u * 1024u * 1024u; - // Node/daemon .dat files that are NOT wallets. The datadir scan already restricts to a "wallet" // prefix, but recursive external folders accept any *.dat โ€” and a user can easily point the picker // at a folder that IS or CONTAINS a datadir, so filter these out everywhere or the list fills with @@ -420,18 +442,7 @@ private: r.dir = p.parent_path().string(); // the file's ACTUAL parent (may be a subdir) โ€” Open reads r.dir + "/" + r.fileName r.inDatadir = inDatadir; r.sizeBytes = static_cast(fs::file_size(p, fec)); - // Read encryption / seed flags straight off the file (no daemon), within the byte budget. - if (probeBudget > 0) { - const auto pr = util::probeWalletFile(p.string(), std::min(probeBudget, kProbePerFile)); - r.probed = pr.isBerkeleyDB; - r.probeComplete = pr.scanComplete; - r.encrypted = pr.encrypted; - r.hdSeed = pr.hdSeed; - // Charge the budget by bytes ACTUALLY read (early-exit reads far less than a big file), - // so a few large wallets don't starve later rows of their probe. - probeBudget -= std::min(probeBudget, pr.bytesRead); - } - s_rows.push_back(std::move(r)); + s_rows.push_back(std::move(r)); // encryption/seed flags are filled in asynchronously (see below) }; // Directory names we never descend into during a recursive external scan โ€” node data subtrees hold // no user wallets and would otherwise burn the visit budget (and flood the list) with block files. @@ -468,6 +479,37 @@ private: if (s_app) for (const auto& f : s_app->walletIndex().extraFolders()) addFrom(f, /*inDatadir=*/false, /*recursive=*/true); + + // Probe the encryption/seed flags on a background thread so a large wallet.dat read never stalls + // the UI. The thread is DETACHED and captures only its own heap batch + a copy of the target paths + // (no static/s_rows access), so it's safe to outlive a re-scan or app shutdown. Results land in the + // shared batch that render() reads under the mutex; badges fill in over the next few frames. + auto batch = std::make_shared(); + batch->results.resize(s_rows.size()); + s_probe = batch; + std::vector> targets; + targets.reserve(s_rows.size()); + for (std::size_t i = 0; i < s_rows.size(); ++i) + targets.emplace_back(s_rows[i].dir + "/" + s_rows[i].fileName, i); + if (!targets.empty()) { + std::thread([batch, targets]() { + // Per-file 256 MB (BDB sorts long-keyed mkey/hdchain LATE, so a small cap risks a + // false-negative); a shared budget bounds total I/O, charged by bytes actually read. + std::size_t budget = 768u * 1024u * 1024u; + constexpr std::size_t kPerFile = 256u * 1024u * 1024u; + for (const auto& t : targets) { + if (batch->cancel.load()) return; + ProbeResult res; + if (budget > 0) { + const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile)); + res = ProbeResult{ pr.isBerkeleyDB, pr.scanComplete, pr.encrypted, pr.hdSeed }; + budget -= std::min(budget, pr.bytesRead); + } + std::lock_guard lk(batch->mtx); + if (t.second < batch->results.size()) batch->results[t.second] = res; + } + }).detach(); + } } static inline bool s_open = false; @@ -475,6 +517,7 @@ private: static inline bool s_needScan = false; static inline char s_newName[128] = ""; static inline std::vector s_rows; + static inline std::shared_ptr s_probe; // current async probe batch (index-aligned w/ s_rows) }; } // namespace ui diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index df9fc90..fbda5fa 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -266,6 +266,10 @@ void I18n::loadBuiltinEnglish() strings_["wallets_badge_seed"] = "Seed phrase wallet (HD)"; strings_["wallets_badge_legacy"] = "Legacy wallet (no seed phrase)"; strings_["wallets_badge_unknown"] = "Wallet type not fully determined (large file โ€” open to confirm)"; + strings_["wallets_badge_seed_short"] = "Seed phrase"; + strings_["wallets_badge_encrypted_short"] = "Encrypted"; + strings_["wallets_badge_legacy_short"] = "Legacy"; + strings_["wallets_badge_unknown_short"] = "Unknown"; strings_["wallets_open"] = "Open"; strings_["wallets_never"] = "Never"; strings_["wallets_import_hint"] = "Import to open";