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:
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user