feat(wallets): vertical label+icon badge stack + async probing

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 12:47:27 -05:00
parent fadbd6a879
commit 36efe99a0f
2 changed files with 102 additions and 55 deletions

View File

@@ -10,11 +10,15 @@
#pragma once #pragma once
#include <algorithm> #include <algorithm>
#include <atomic>
#include <cctype> #include <cctype>
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <filesystem> #include <filesystem>
#include <memory>
#include <mutex>
#include <string> #include <string>
#include <thread>
#include <vector> #include <vector>
#include "imgui.h" #include "imgui.h"
@@ -171,24 +175,36 @@ public:
const float btnX = rMax.x - padX - btnW; const float btnX = rMax.x - padX - btnW;
const float textR = btnX - Layout::spacingMd(); 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; 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; const float badgeSz = metaFont->LegacySize;
// Only positive detections are definitive; a MISSING marker is trustworthy solely when the // Positive detections are definitive; a MISSING marker is trustworthy only when the whole
// whole file was scanned (r.probeComplete). So: lock iff encrypted; seed iff hdSeed; "legacy" // file was scanned (probeComplete): seed iff hdSeed; "legacy" only when complete & no seed;
// only when complete AND no seed; and when the scan was truncated without confirming // "encrypted" iff mkey found; and an "unknown" row when a truncated scan couldn't confirm
// encryption, an explicit "unknown" badge — so absence-of-lock never falsely reads as // encryption — so absence of a lock never falsely reads as "unencrypted" on a huge wallet.
// "unencrypted" on a huge wallet whose mkey record sat past the probe cap. ProbeResult pres; // filled in asynchronously by the background probe thread
const bool bLock = r.probed && r.encrypted; if (s_probe) { std::lock_guard<std::mutex> lk(s_probe->mtx);
const bool bSeed = r.probed && r.hdSeed; if (i < s_probe->results.size()) pres = s_probe->results[i]; }
const bool bLegacy = r.probed && r.probeComplete && !r.hdSeed; const bool bLock = pres.probed && pres.encrypted;
const bool bUnknown = r.probed && !r.probeComplete && !r.encrypted; const bool bSeed = pres.probed && pres.hdSeed;
const int nBadges = (bLock ? 1 : 0) + (bSeed ? 1 : 0) + (bLegacy ? 1 : 0) + (bUnknown ? 1 : 0); const bool bLegacy = pres.probed && pres.complete && !pres.hdSeed;
const float badgeReserve = nBadges > 0 ? nBadges * (badgeSz + Layout::spacingSm()) + Layout::spacingSm() : 0.0f; const bool bUnknown = pres.probed && !pres.complete && !pres.encrypted;
float extra = ((!r.inDatadir) ? (metaFont->LegacySize + Layout::spacingSm()) : 0.0f) + badgeReserve; struct Badge { const char* glyph; ImU32 col; const char* label; const char* tip; };
std::string nm = fit(r.fileName, nameFont, std::max(24.0f * dp, textR - x - extra)); 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()); dl->AddText(nameFont, nameFont->LegacySize, ImVec2(x, topY), OnSurface(), nm.c_str());
if (!r.inDatadir) { if (!r.inDatadir) {
ImVec2 ip(x + tw(nameFont, nm) + Layout::spacingSm(), topY + (nameFont->LegacySize - metaFont->LegacySize)); 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))) 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()); Tooltip("%s\n%s", TR("wallets_external_tt"), r.dir.c_str());
} }
if (nBadges > 0) { if (nb > 0) {
float bx = textR; const float lineH = metaFont->LegacySize;
auto badge = [&](const char* glyph, ImU32 col, const char* tip) { const float vGap = Layout::spacingXs();
bx -= badgeSz; const float totalH = nb * lineH + (nb - 1) * vGap;
ImVec2 bp(bx, topY + (nameFont->LegacySize - badgeSz)); const float sy = midY - totalH * 0.5f;
dl->AddText(icoFont, badgeSz, bp, col, glyph); for (int k = 0; k < nb; ++k) {
if (ImGui::IsMouseHoveringRect(bp, ImVec2(bp.x + badgeSz, bp.y + badgeSz))) Tooltip("%s", tip); const float ey = sy + k * (lineH + vGap);
bx -= Layout::spacingSm(); const float lw = tw(metaFont, bl[k].label);
}; dl->AddText(metaFont, lineH, ImVec2(textR - badgeSz - bGap - lw, ey), bl[k].col, bl[k].label);
if (bLock) badge(ICON_MD_LOCK, WithAlpha(Warning(), 235), TR("wallets_badge_encrypted")); dl->AddText(icoFont, badgeSz, ImVec2(textR - badgeSz, ey), bl[k].col, bl[k].glyph);
if (bSeed) badge(ICON_MD_ECO, WithAlpha(Success(), 220), TR("wallets_badge_seed")); if (ImGui::IsMouseHoveringRect(ImVec2(textR - badgeSz - bGap - lw, ey), ImVec2(textR, ey + lineH)))
if (bLegacy) badge(ICON_MD_HISTORY, WithAlpha(OnSurfaceMedium(), 210), TR("wallets_badge_legacy")); Tooltip("%s", bl[k].tip);
if (bUnknown) badge(ICON_MD_HELP_OUTLINE, WithAlpha(OnSurfaceMedium(), 170), TR("wallets_badge_unknown")); }
} }
// Metadata line (size · N addresses · balance DRGX · last opened) // 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; } if (meta && meta->cachedBalance >= 0.0) { snprintf(b, sizeof(b), "%s%.4f %s", dot, meta->cachedBalance, DRAGONX_TICKER); ms += b; }
ms += dot; ms += dot;
ms += (meta && meta->lastOpenedEpoch > 0) ? util::formatTimeAgo(meta->lastOpenedEpoch) : std::string(TR("wallets_never")); 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()), dl->AddText(metaFont, metaFont->LegacySize, ImVec2(x, topY + nameFont->LegacySize + Layout::spacingXs()),
OnSurfaceMedium(), ms.c_str()); OnSurfaceMedium(), ms.c_str());
@@ -323,11 +339,22 @@ private:
std::string dir; std::string dir;
bool inDatadir = false; bool inDatadir = false;
long long sizeBytes = 0; 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 // Light facts read off each wallet.dat WITHOUT loading it (util/wallet_file_probe.h), computed on a
bool encrypted = false; // passphrase-encrypted (has an mkey record) // background thread so scan() never blocks the UI on a big file read. Results live in a shared,
bool hdSeed = false; // HD/seed wallet (else legacy) — ties into migrate-to-seed // 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<ProbeResult> results; // index-aligned with s_rows at the moment of scan()
std::atomic<bool> cancel{false};
}; };
// Force a "wallet-...dat" filename (plain, no path) so a new/imported wallet shows in the // 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 // 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. // never follows directory symlinks (std default), so it can't loop or stall the UI on a huge tree.
static void scan() { 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(); s_rows.clear();
namespace fs = std::filesystem; namespace fs = std::filesystem;
// Guards for recursive external-folder scans (a user could point us at a large tree / home dir). // 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 size_t kMaxHits = 200; // cap total wallet files listed
constexpr int kMaxVisited = 40000; // cap total entries walked, so scan() can't stall the UI 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" // 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 // 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 // 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.dir = p.parent_path().string(); // the file's ACTUAL parent (may be a subdir) — Open reads r.dir + "/" + r.fileName
r.inDatadir = inDatadir; r.inDatadir = inDatadir;
r.sizeBytes = static_cast<long long>(fs::file_size(p, fec)); r.sizeBytes = static_cast<long long>(fs::file_size(p, fec));
// Read encryption / seed flags straight off the file (no daemon), within the byte budget. s_rows.push_back(std::move(r)); // encryption/seed flags are filled in asynchronously (see below)
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));
}; };
// Directory names we never descend into during a recursive external scan — node data subtrees hold // 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. // 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) if (s_app)
for (const auto& f : s_app->walletIndex().extraFolders()) for (const auto& f : s_app->walletIndex().extraFolders())
addFrom(f, /*inDatadir=*/false, /*recursive=*/true); 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<ProbeBatch>();
batch->results.resize(s_rows.size());
s_probe = batch;
std::vector<std::pair<std::string, std::size_t>> 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<std::mutex> lk(batch->mtx);
if (t.second < batch->results.size()) batch->results[t.second] = res;
}
}).detach();
}
} }
static inline bool s_open = false; static inline bool s_open = false;
@@ -475,6 +517,7 @@ private:
static inline bool s_needScan = false; static inline bool s_needScan = false;
static inline char s_newName[128] = ""; static inline char s_newName[128] = "";
static inline std::vector<WalletRow> s_rows; static inline std::vector<WalletRow> s_rows;
static inline std::shared_ptr<ProbeBatch> s_probe; // current async probe batch (index-aligned w/ s_rows)
}; };
} // namespace ui } // namespace ui

View File

@@ -266,6 +266,10 @@ void I18n::loadBuiltinEnglish()
strings_["wallets_badge_seed"] = "Seed phrase wallet (HD)"; strings_["wallets_badge_seed"] = "Seed phrase wallet (HD)";
strings_["wallets_badge_legacy"] = "Legacy wallet (no seed phrase)"; 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_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_open"] = "Open";
strings_["wallets_never"] = "Never"; strings_["wallets_never"] = "Never";
strings_["wallets_import_hint"] = "Import to open"; strings_["wallets_import_hint"] = "Import to open";