Parse the earliest keymeta nCreateTime out of the wallet.dat btree (the daemon's
"wallet birthday") and surface it in each row's metadata line ("created Aug 2025").
Add a sort control above the list — Date created / Address count / Transaction
count / Wallet size, with an ascending/descending toggle (descending default:
newest / most / largest first). Sorting reorders a display-index array
(s_order) rather than s_rows, so the async, index-aligned probe batch is
untouched; sort keys read a single frame-consistent snapshot of the probe
results. Address count prefers the authoritative cached index value, else the
btree key count.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
610 lines
36 KiB
C++
610 lines
36 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
//
|
|
// Wallet-files list + switcher (multi-wallet P2). Lists wallet files in the datadir (and any
|
|
// user-added folders) with cached metadata (size from disk; balance / address count / last-opened
|
|
// from the wallet index, since those can't be read off a wallet.dat without loading it). "Open"
|
|
// switches the active wallet via App::switchToWallet (daemon restart on -wallet=<name>).
|
|
|
|
#pragma once
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <cctype>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <ctime>
|
|
#include <filesystem>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#include "imgui.h"
|
|
|
|
#include "../../app.h"
|
|
#include "../../config/settings.h"
|
|
#include "../../config/version.h"
|
|
#include "../../util/i18n.h"
|
|
#include "../../util/platform.h"
|
|
#include "../../util/text_format.h"
|
|
#include "../../util/wallet_file_probe.h"
|
|
#include "../../embedded/IconsMaterialDesign.h"
|
|
#include "../notifications.h"
|
|
#include "../layout.h"
|
|
#include "../material/colors.h"
|
|
#include "../material/draw_helpers.h"
|
|
#include "../material/type.h"
|
|
#include "folder_picker.h"
|
|
|
|
namespace dragonx {
|
|
namespace ui {
|
|
|
|
class WalletsDialog {
|
|
public:
|
|
static void show(App* app) {
|
|
s_open = true;
|
|
s_app = app;
|
|
s_needScan = true;
|
|
}
|
|
|
|
static void hide() { s_open = false; }
|
|
|
|
static void render() {
|
|
if (!s_open || !s_app) return;
|
|
// The folder picker (opened by "Scan another folder") takes over the modal surface while
|
|
// it's up — the overlay framework doesn't nest, so only one overlay renders at a time.
|
|
if (FolderPicker::isOpen()) { FolderPicker::render(); return; }
|
|
using namespace material;
|
|
App* app = s_app;
|
|
const float dp = Layout::dpiScale();
|
|
ImGuiStyle& style = ImGui::GetStyle();
|
|
|
|
if (s_needScan) { scan(); s_needScan = false; }
|
|
|
|
// ---- Content-sized card: a stack of wallet cards, sized to the wallet count (up to
|
|
// kMaxVisibleRows, then the list scrolls). Computed from the KNOWN row count so the
|
|
// dialog stays fixed-height (no auto-height feedback that slid content off on a move). --
|
|
const int kMaxVisibleRows = 7;
|
|
ImFont* nameFont = Type().subtitle1();
|
|
ImFont* metaFont = Type().caption();
|
|
const int visRows = std::min(std::max(1, (int)s_rows.size()), kMaxVisibleRows);
|
|
const float cardPadY = Layout::spacingMd(); // roomier cards (was spacingSm)
|
|
const float walRowH = cardPadY * 2.0f + nameFont->LegacySize + Layout::spacingSm() + metaFont->LegacySize;
|
|
const float cardGap = Layout::spacingMd(); // more breathing room between wallet cards
|
|
// ItemSpacing is zeroed inside the list child, so the content is exactly cards + inter-card
|
|
// gaps; a small bottom pad keeps the last card off the clip edge (no spurious scrollbar).
|
|
const float listH = visRows * walRowH + (float)std::max(0, visRows - 1) * cardGap + Layout::spacingSm();
|
|
|
|
const float ctrlRow = ImGui::GetFrameHeightWithSpacing();
|
|
const float capRow = Type().caption()->LegacySize + style.ItemSpacing.y;
|
|
const float belowH = 4.0f * Layout::spacingSm()
|
|
+ (capRow + ctrlRow) // Create label + input row
|
|
+ ctrlRow // full-width "scan folder" button
|
|
+ (style.ItemSpacing.y + 1.0f) // separator
|
|
+ ctrlRow // footer buttons
|
|
+ 6.0f * style.ItemSpacing.y; // uncounted inter-item gaps
|
|
const float headH = Type().h6()->LegacySize + Layout::spacingXs() // framework h6 title
|
|
+ Type().caption()->LegacySize + Layout::spacingSm() // intro + gap
|
|
+ ctrlRow + Layout::spacingSm(); // sort control row + gap
|
|
const float padV = 48.0f; // content-child padding (top+bottom) + margin
|
|
// Size to content, but cap at a viewport fraction: with many wallets on a small / HiDPI
|
|
// screen the content-sized card could exceed the window (the framework would clip a too-tall
|
|
// card, footer and all). When capped, the wallet list flexes + scrolls and the controls
|
|
// below it stay pinned. In the common case (few wallets) nothing changes.
|
|
const float desiredH = headH + listH + belowH + padV; // physical
|
|
const float vpH = ImGui::GetMainViewport()->Size.y;
|
|
const bool capped = desiredH > vpH * 0.86f;
|
|
const float cardH = std::min(desiredH, vpH * 0.86f) / dp;
|
|
|
|
OverlayDialogSpec ov;
|
|
ov.title = TR("wallets_title");
|
|
ov.p_open = &s_open;
|
|
ov.style = OverlayStyle::BlurFloat;
|
|
ov.cardWidth = 780.0f;
|
|
ov.cardHeight = cardH;
|
|
ov.idSuffix = "wallets";
|
|
if (BeginOverlayDialog(ov)) {
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_intro"));
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
|
|
const std::string active = app->settings() ? app->settings()->getActiveWalletFile() : "wallet.dat";
|
|
|
|
// ---- Frame-consistent snapshot of the async probe results (badges / counts / sort all read
|
|
// the same view; one lock instead of one-per-row). ----------------------------------
|
|
std::vector<ProbeResult> probeSnap;
|
|
if (s_probe) { std::lock_guard<std::mutex> lk(s_probe->mtx); probeSnap = s_probe->results; }
|
|
auto probeAt = [&](std::size_t idx) -> ProbeResult {
|
|
return idx < probeSnap.size() ? probeSnap[idx] : ProbeResult{};
|
|
};
|
|
|
|
// ---- Sort controls: pick the key; the arrow flips ascending/descending -----------------
|
|
{
|
|
ImGui::AlignTextToFramePadding();
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_sort_by"));
|
|
ImGui::SameLine();
|
|
const char* items[] = { TR("wallets_sort_created"), TR("wallets_sort_addresses"),
|
|
TR("wallets_sort_txs"), TR("wallets_sort_size") };
|
|
ImGui::SetNextItemWidth(190.0f * dp);
|
|
ImGui::Combo("##walletSort", &s_sortMode, items, IM_ARRAYSIZE(items));
|
|
ImGui::SameLine();
|
|
const float dbH = ImGui::GetFrameHeight();
|
|
if (StyledButton(s_sortDesc ? ICON_MD_ARROW_DOWNWARD : ICON_MD_ARROW_UPWARD,
|
|
ImVec2(dbH, dbH), Type().iconSmall()))
|
|
s_sortDesc = !s_sortDesc;
|
|
if (ImGui::IsItemHovered()) Tooltip("%s", TR(s_sortDesc ? "wallets_sort_desc" : "wallets_sort_asc"));
|
|
}
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
|
|
// ---- Compute the display order for the active sort key (stable, so ties keep scan order).
|
|
s_order.resize(s_rows.size());
|
|
for (std::size_t k = 0; k < s_rows.size(); ++k) s_order[k] = k;
|
|
{
|
|
std::vector<long long> sortVal(s_rows.size(), 0);
|
|
for (std::size_t k = 0; k < s_rows.size(); ++k) {
|
|
const WalletRow& r = s_rows[k];
|
|
const ProbeResult pr = probeAt(k);
|
|
switch (s_sortMode) {
|
|
case 1: { const auto* m = r.inDatadir ? app->walletIndex().find(r.fileName) : nullptr;
|
|
sortVal[k] = (m && m->cachedAddressCount >= 0) ? (long long)m->cachedAddressCount
|
|
: pr.keyCount; break; }
|
|
case 2: sortVal[k] = pr.txCount; break;
|
|
case 3: sortVal[k] = r.sizeBytes; break;
|
|
default: sortVal[k] = pr.createdEpoch; break; // 0 = date created
|
|
}
|
|
}
|
|
std::stable_sort(s_order.begin(), s_order.end(), [&](std::size_t a, std::size_t b) {
|
|
return s_sortDesc ? sortVal[a] > sortVal[b] : sortVal[a] < sortVal[b];
|
|
});
|
|
}
|
|
|
|
// ---- Wallet cards (Material-style rows; no table) -------------------------------------
|
|
const float listW = ImGui::GetContentRegionAvail().x;
|
|
// Flex only when the card was viewport-capped: give the list the space left above the
|
|
// create/scan/footer controls so it scrolls internally and those stay visible. Uncapped,
|
|
// it equals the content height exactly (no spurious scrollbar — the common case).
|
|
float listHFit = listH;
|
|
if (capped) listHFit = std::max(walRowH, ImGui::GetContentRegionAvail().y - belowH);
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
|
// NoScrollWithMouse + ApplySmoothScroll gives the wheel the same eased scrolling as the
|
|
// Settings page (ApplySmoothScroll handles the wheel itself, so let it own that input).
|
|
ImGui::BeginChild("##walletList", ImVec2(listW, listHFit), false, ImGuiWindowFlags_NoScrollWithMouse);
|
|
ImGui::PopStyleVar();
|
|
ApplySmoothScroll();
|
|
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0)); // inter-card gaps are explicit
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
const float rowW = ImGui::GetContentRegionAvail().x;
|
|
ImFont* icoFont = Type().iconSmall();
|
|
const char* dot = " \xC2\xB7 ";
|
|
auto tw = [](ImFont* f, const std::string& s){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x; };
|
|
auto fit = [&](std::string s, ImFont* f, float maxW){
|
|
bool t=false;
|
|
while (s.size()>1 && tw(f,s)>maxW){
|
|
// Pop a whole UTF-8 code point (never split a multibyte char — external .dat names
|
|
// and some locale metadata are non-ASCII).
|
|
while (s.size()>1 && (static_cast<unsigned char>(s.back()) & 0xC0) == 0x80) s.pop_back();
|
|
if (s.size()>1) s.pop_back();
|
|
t=true;
|
|
}
|
|
if (t) s += "\xE2\x80\xA6";
|
|
return s;
|
|
};
|
|
|
|
for (std::size_t k = 0; k < s_order.size(); ++k) {
|
|
const std::size_t i = s_order[k]; // sorted display order → actual s_rows index
|
|
const WalletRow& r = s_rows[i];
|
|
// The wallet index is keyed by bare filename and is only ever written for the active
|
|
// DATADIR wallet — so an external row (esp. a same-named one surfaced by the recursive
|
|
// subdir scan) must NOT borrow a datadir wallet's cached balance/addresses. Only look up
|
|
// metadata for datadir rows; external rows show size + "never opened" (accurate).
|
|
const data::WalletIndexEntry* meta = r.inDatadir ? app->walletIndex().find(r.fileName) : nullptr;
|
|
const bool isCurrent = r.inDatadir && r.fileName == active;
|
|
|
|
ImVec2 rMin = ImGui::GetCursorScreenPos();
|
|
ImVec2 rMax(rMin.x + rowW, rMin.y + walRowH);
|
|
bool hov = ImGui::IsWindowHovered() && ImGui::IsMouseHoveringRect(rMin, rMax);
|
|
|
|
// Card background + state
|
|
GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = hov ? 40 : 26; g.borderAlpha = 45;
|
|
DrawGlassPanel(dl, rMin, rMax, g);
|
|
if (isCurrent) {
|
|
dl->AddRectFilled(rMin, rMax, WithAlpha(Success(), 16), 10.0f);
|
|
dl->AddRect(rMin, rMax, WithAlpha(Success(), 130), 10.0f, 0, 1.6f * dp);
|
|
} else if (hov) {
|
|
dl->AddRect(rMin, rMax, WithAlpha(OnSurface(), 70), 10.0f, 0, 1.0f);
|
|
}
|
|
|
|
const float padX = Layout::spacingMd();
|
|
const float iconSz = nameFont->LegacySize * 1.15f;
|
|
const float midY = (rMin.y + rMax.y) * 0.5f;
|
|
float x = rMin.x + padX;
|
|
dl->AddText(icoFont, iconSz, ImVec2(x, midY - iconSz * 0.5f),
|
|
isCurrent ? Success() : OnSurfaceMedium(), ICON_MD_ACCOUNT_BALANCE_WALLET);
|
|
x += iconSz + Layout::spacingMd();
|
|
|
|
// Action-button slot on the right — reserve room so text truncates before it.
|
|
const float btnW = 96.0f * dp, btnH = ImGui::GetFrameHeight();
|
|
const float btnX = rMax.x - padX - btnW;
|
|
const float textR = btnX - Layout::spacingMd();
|
|
|
|
// 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 badgeSz = metaFont->LegacySize;
|
|
// 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.
|
|
const ProbeResult pres = probeAt(i); // from the frame-consistent snapshot above
|
|
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));
|
|
dl->AddText(icoFont, metaFont->LegacySize, ip, OnSurfaceMedium(), ICON_MD_INFO);
|
|
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 (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/keys · M txs · balance DRGX · last opened). The cached
|
|
// wallet index gives the authoritative user-facing ADDRESS count; the btree walk gives an
|
|
// exact tx count and a spendable-KEY count (labeled "keys", not "addresses", since it counts
|
|
// change keys the daemon's address list omits — a different figure for the same wallet).
|
|
std::string ms = util::Platform::formatFileSize((uint64_t)r.sizeBytes);
|
|
char b[80];
|
|
if (meta && meta->cachedAddressCount >= 0) { snprintf(b, sizeof(b), "%s%lld %s", dot, (long long)meta->cachedAddressCount, TR("wallets_col_addresses")); ms += b; }
|
|
else if (pres.hasCounts && pres.keyCount > 0) { snprintf(b, sizeof(b), "%s%d %s", dot, pres.keyCount, TR("wallets_col_keys")); ms += b; }
|
|
if (pres.hasCounts) { snprintf(b, sizeof(b), "%s%d %s", dot, pres.txCount, TR("wallets_col_txs")); ms += b; }
|
|
if (pres.hasCounts && pres.createdEpoch > 0) { // wallet birthday, e.g. "created Aug 2025"
|
|
std::time_t ct = static_cast<std::time_t>(pres.createdEpoch);
|
|
char cd[24]; std::strftime(cd, sizeof(cd), "%b %Y", std::localtime(&ct));
|
|
snprintf(b, sizeof(b), "%s%s %s", dot, TR("wallets_created"), cd); 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 += (meta && meta->lastOpenedEpoch > 0) ? util::formatTimeAgo(meta->lastOpenedEpoch) : std::string(TR("wallets_never"));
|
|
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());
|
|
|
|
// Action: Active chip (current) / Open / Import
|
|
ImGui::PushID(static_cast<int>(i));
|
|
if (isCurrent) {
|
|
const char* al = TR("wallets_active");
|
|
float aw = tw(metaFont, al), cw = aw + 16.0f * dp, ch = metaFont->LegacySize + 6.0f * dp;
|
|
ImVec2 cp(rMax.x - padX - cw, midY - ch * 0.5f);
|
|
dl->AddRectFilled(cp, ImVec2(cp.x + cw, cp.y + ch), WithAlpha(Success(), 34), 5.0f * dp);
|
|
dl->AddText(metaFont, metaFont->LegacySize, ImVec2(cp.x + 8.0f * dp, cp.y + 3.0f * dp), Success(), al);
|
|
} else if (r.inDatadir) {
|
|
ImGui::SetCursorScreenPos(ImVec2(btnX, midY - btnH * 0.5f));
|
|
if (StyledButton(TR("wallets_open"), ImVec2(btnW, btnH))) {
|
|
app->switchToWallet(r.fileName);
|
|
s_open = false;
|
|
}
|
|
} else {
|
|
ImGui::SetCursorScreenPos(ImVec2(btnX, midY - btnH * 0.5f));
|
|
if (StyledButton(TR("wallets_import"), ImVec2(btnW, btnH)))
|
|
importAndOpen(app, r);
|
|
if (ImGui::IsItemHovered()) Tooltip("%s", TR("wallets_import_tt"));
|
|
}
|
|
ImGui::PopID();
|
|
|
|
// Register the card's footprint with a Dummy (not an interactive item, so nothing
|
|
// overlaps the action button's ID) and add an explicit gap between cards (ItemSpacing
|
|
// is 0, so no double spacing; the last card has no trailing gap).
|
|
ImGui::SetCursorScreenPos(rMin);
|
|
ImGui::Dummy(ImVec2(rowW, walRowH));
|
|
if (i + 1 < s_rows.size()) ImGui::Dummy(ImVec2(rowW, cardGap));
|
|
}
|
|
ImGui::PopStyleVar(); // ItemSpacing
|
|
ImGui::EndChild();
|
|
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
|
|
// ---- Create a new wallet (primary) — the input + button span the full list width -----
|
|
const float createBtnW = 140.0f * dp;
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_new_label"));
|
|
ImGui::SetNextItemWidth(listW - createBtnW - style.ItemSpacing.x);
|
|
ImGui::InputTextWithHint("##newWalletName", TR("wallets_new_hint"), s_newName, sizeof(s_newName));
|
|
ImGui::SameLine();
|
|
if (StyledButton(TR("wallets_create"), ImVec2(createBtnW, 0))) {
|
|
std::string name = normalizeWalletName(s_newName);
|
|
std::error_code ec;
|
|
if (name.empty()) {
|
|
Notifications::instance().warning(TR("wallets_name_invalid"));
|
|
} else if (std::filesystem::exists(util::Platform::getDragonXDataDir() + "/" + name, ec)) {
|
|
Notifications::instance().warning(TR("wallets_exists"));
|
|
} else {
|
|
s_newName[0] = '\0';
|
|
Notifications::instance().info(TR("wallets_creating"));
|
|
app->switchToWallet(name);
|
|
s_open = false;
|
|
}
|
|
}
|
|
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
|
|
// ---- Scan another folder — always visible, full-width, opens the in-app folder picker -
|
|
// Start in the DRAGONX data directory (where the datadir wallets live) so the user can
|
|
// navigate up/out from a familiar anchor to find wallets in other folders.
|
|
if (StyledButton(TR("wallets_scan_folder"), ImVec2(listW, 0))) {
|
|
FolderPicker::open(util::Platform::getDragonXDataDir(), [app](const std::string& dir) {
|
|
std::error_code ec;
|
|
if (!dir.empty() && std::filesystem::is_directory(dir, ec)) {
|
|
if (app->walletIndex().addExtraFolder(dir)) app->walletIndex().save();
|
|
s_needScan = true;
|
|
} else {
|
|
Notifications::instance().warning(TR("wallets_folder_invalid"));
|
|
}
|
|
});
|
|
}
|
|
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
ImGui::Separator();
|
|
// ---- Refresh: icon-only, de-emphasized next to the text actions -----------------------
|
|
{
|
|
float bh = ImGui::GetFrameHeight();
|
|
IconButtonStyle rst;
|
|
rst.color = OnSurfaceMedium();
|
|
rst.hoverBg = StateHover();
|
|
rst.bgRounding = 4.0f * dp;
|
|
rst.tooltip = TR("refresh");
|
|
if (IconButton("##walletsRefresh", ICON_MD_REFRESH, Type().iconSmall(), ImVec2(bh, bh), rst))
|
|
s_needScan = true;
|
|
}
|
|
ImGui::SameLine();
|
|
if (StyledButton(TR("wallets_reveal"), ImVec2(150.0f * dp, 0)))
|
|
util::Platform::openFolder(util::Platform::getDragonXDataDir());
|
|
ImGui::SameLine();
|
|
if (StyledButton(TR("close"), ImVec2(110.0f * dp, 0))) s_open = false;
|
|
|
|
EndOverlayDialog();
|
|
}
|
|
}
|
|
|
|
private:
|
|
struct WalletRow {
|
|
std::string fileName;
|
|
std::string dir;
|
|
bool inDatadir = false;
|
|
long long sizeBytes = 0;
|
|
};
|
|
|
|
// 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)
|
|
bool hasCounts = false; // an exact btree walk produced the counts below
|
|
int keyCount = 0; // transparent + shielded spendable keys (≈ addresses, incl. change)
|
|
int txCount = 0; // wallet transaction records
|
|
long long createdEpoch = 0; // wallet birthday (earliest keymeta nCreateTime); 0 = unknown
|
|
};
|
|
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
|
|
// datadir scan and can't collide with the daemon's internal .dat files. Returns "" if the
|
|
// input has no usable characters.
|
|
static std::string normalizeWalletName(const std::string& raw) {
|
|
std::string s = raw;
|
|
while (!s.empty() && static_cast<unsigned char>(s.front()) <= ' ') s.erase(s.begin());
|
|
while (!s.empty() && static_cast<unsigned char>(s.back()) <= ' ') s.pop_back();
|
|
auto slash = s.find_last_of("/\\");
|
|
if (slash != std::string::npos) s = s.substr(slash + 1);
|
|
if (s.size() > 4 && s.substr(s.size() - 4) == ".dat") s = s.substr(0, s.size() - 4);
|
|
std::string clean;
|
|
for (char c : s)
|
|
if (std::isalnum(static_cast<unsigned char>(c)) || c == '-' || c == '_') clean += c;
|
|
if (clean.empty()) return "";
|
|
if (clean.rfind("wallet", 0) != 0) clean = "wallet-" + clean;
|
|
return clean + ".dat";
|
|
}
|
|
|
|
// Copy an out-of-datadir wallet file into the datadir (the daemon only loads plain filenames
|
|
// from there) under a wallet-*.dat name, then switch to it. Never overwrites an existing file.
|
|
static void importAndOpen(App* app, const WalletRow& r) {
|
|
namespace fs = std::filesystem;
|
|
const std::string base = normalizeWalletName(r.fileName);
|
|
if (base.empty()) { Notifications::instance().warning(TR("wallets_name_invalid")); return; }
|
|
std::error_code ec;
|
|
const std::string datadir = util::Platform::getDragonXDataDir();
|
|
fs::create_directories(datadir, ec);
|
|
// The recursive subdir scan can surface several distinct wallets sharing one basename (e.g. a
|
|
// "wallet.dat" in two backup folders). Auto-suffix the datadir destination to the next free name
|
|
// instead of hard-failing, so the second one is importable too. Never overwrites an existing file.
|
|
const std::string stem = base.substr(0, base.size() - 4); // strip ".dat"
|
|
std::string dest = base;
|
|
std::string destPath = datadir + "/" + dest;
|
|
for (int n = 2; n < 1000 && fs::exists(destPath, ec); ++n) {
|
|
dest = stem + "-" + std::to_string(n) + ".dat";
|
|
destPath = datadir + "/" + dest;
|
|
}
|
|
if (fs::exists(destPath, ec)) { Notifications::instance().warning(TR("wallets_exists")); return; }
|
|
fs::copy_file(r.dir + "/" + r.fileName, destPath, ec);
|
|
if (ec) { Notifications::instance().error(TR("wallets_import_failed")); return; }
|
|
Notifications::instance().success(TR("wallets_imported"));
|
|
app->switchToWallet(dest);
|
|
s_open = false;
|
|
}
|
|
|
|
// Scan the datadir (wallet-prefixed *.dat only, to skip peers.dat / asmap.dat / fee_estimates.dat)
|
|
// and each user-added folder (any *.dat). User-added folders are searched RECURSIVELY into
|
|
// subdirectories; the datadir stays top-level only (never descend into blocks/ chainstate/ …).
|
|
// 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).
|
|
constexpr int kMaxDepth = 8; // don't descend deeper than this below the chosen folder
|
|
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
|
|
|
|
// 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
|
|
// blk*/rev* block files, peers.dat, etc. presented as importable "wallets".
|
|
auto isNodeArtifact = [](const std::string& n) {
|
|
static const char* kExact[] = { "peers.dat", "banlist.dat", "fee_estimates.dat",
|
|
"mempool.dat", "asmap.dat", "zindex.dat" };
|
|
for (const char* e : kExact) if (n == e) return true;
|
|
return n.rfind("blk", 0) == 0 || n.rfind("rev", 0) == 0; // blk00000.dat / rev00000.dat
|
|
};
|
|
auto consider = [&](const fs::path& p, bool inDatadir) {
|
|
// String-only checks FIRST (no syscalls) — the vast majority of entries in a large tree are
|
|
// neither .dat nor wallets, so reject them before paying for a stat().
|
|
const std::string name = p.filename().string();
|
|
if (name.size() <= 4 || name.substr(name.size() - 4) != ".dat") return;
|
|
if (inDatadir) { if (name.rfind("wallet", 0) != 0) return; } // datadir: wallet-prefixed only
|
|
else { if (isNodeArtifact(name)) return; } // external: reject node junk
|
|
std::error_code fec;
|
|
if (!fs::is_regular_file(p, fec)) return;
|
|
WalletRow r;
|
|
r.fileName = name;
|
|
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<long long>(fs::file_size(p, fec));
|
|
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.
|
|
auto isNodeSubtree = [](const std::string& n) {
|
|
return n == "blocks" || n == "chainstate" || n == "database" ||
|
|
n == "indexes" || n == "index";
|
|
};
|
|
auto addFrom = [&](const std::string& dir, bool inDatadir, bool recursive) {
|
|
std::error_code ec;
|
|
if (dir.empty() || !fs::is_directory(dir, ec)) return;
|
|
if (!recursive) {
|
|
fs::directory_iterator it(dir, ec), end;
|
|
for (; !ec && it != end; it.increment(ec)) consider(it->path(), inDatadir);
|
|
return;
|
|
}
|
|
// skip_permission_denied keeps a locked subdir from aborting the whole walk; the iterator
|
|
// does NOT follow directory symlinks by default, so symlink cycles can't cause infinite loops.
|
|
fs::recursive_directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec), end;
|
|
int visited = 0;
|
|
for (; !ec && it != end; it.increment(ec)) {
|
|
if (++visited > kMaxVisited) break;
|
|
std::error_code dec;
|
|
if (it->is_directory(dec)) {
|
|
// Prune: don't descend past the depth cap or into node data subtrees.
|
|
if (it.depth() >= kMaxDepth || isNodeSubtree(it->path().filename().string()))
|
|
it.disable_recursion_pending();
|
|
continue; // directories aren't wallet files
|
|
}
|
|
consider(it->path(), inDatadir);
|
|
if (s_rows.size() >= kMaxHits) break;
|
|
}
|
|
};
|
|
addFrom(util::Platform::getDragonXDataDir(), /*inDatadir=*/true, /*recursive=*/false);
|
|
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<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);
|
|
|
|
auto runProbe = [](std::shared_ptr<ProbeBatch> batch,
|
|
std::vector<std::pair<std::string, std::size_t>> 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) {
|
|
// Primary: an exact btree walk (encryption/seed flags + address/tx counts). If it
|
|
// can't fully parse (unknown BDB variant, or a >cap file), fall back to the cheap
|
|
// byte-scan for badges only (no counts).
|
|
const auto bt = util::parseWalletBtree(t.first, std::min(budget, kPerFile));
|
|
if (bt.parsed && bt.complete) {
|
|
res = ProbeResult{ true, true, bt.encrypted, bt.hdSeed, true, bt.addresses(), bt.txCount, bt.createdEpoch };
|
|
budget -= std::min(budget, bt.bytesRead);
|
|
} else {
|
|
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, std::max(bt.bytesRead, pr.bytesRead));
|
|
}
|
|
}
|
|
std::lock_guard<std::mutex> lk(batch->mtx);
|
|
if (t.second < batch->results.size()) batch->results[t.second] = res;
|
|
}
|
|
};
|
|
if (targets.empty()) {
|
|
// nothing to probe
|
|
} else if (s_app && s_app->isScreenshotSweeping()) {
|
|
runProbe(batch, targets); // offline sweep: probe synchronously so the captured frame is populated
|
|
} else {
|
|
std::thread(runProbe, batch, targets).detach();
|
|
}
|
|
}
|
|
|
|
static inline bool s_open = false;
|
|
static inline App* s_app = nullptr;
|
|
static inline bool s_needScan = false;
|
|
static inline char s_newName[128] = "";
|
|
static inline std::vector<WalletRow> s_rows;
|
|
static inline std::shared_ptr<ProbeBatch> s_probe; // current async probe batch (index-aligned w/ s_rows)
|
|
static inline std::vector<std::size_t> s_order; // display order (indices into s_rows) for the active sort
|
|
static inline int s_sortMode = 0; // 0 created · 1 addresses · 2 txs · 3 size
|
|
static inline bool s_sortDesc = true; // newest / most / largest first
|
|
};
|
|
|
|
} // namespace ui
|
|
} // namespace dragonx
|