Files
ObsidianDragon/src/ui/windows/wallets_dialog.h
DanS 49b5ef6b20 fix(wallets): key in-place links on the canonical path (one wallet, one link)
A wallet reached via two different path strings — e.g. a symlinked mount
(/mnt/usb) and its real target (/media/usb) — used to hash to two different
in-place link names and list as two rows, so the same file looked like two
wallets with two datadir links.

Give WalletRow a canonPath (fs::canonical, symlinks resolved, computed once at
scan time so the every-frame render/active-marking path stays cheap) and key
both the link name and the row de-dup on it. Now the same physical wallet maps
to one identity regardless of the path used to reach it: one link name (honors
the "distinct per-target name" invariant), and scan() lists it once — datadir
first, so a datadir wallet wins over the same file seen via an external path.
The link also points straight at the resolved real file instead of through the
symlinked path. Genuinely distinct wallets keep distinct identities (verified
with a standalone symlinked-mount harness).

Closes the last deferred finding from the open-in-place adversarial review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:47:12 -05:00

731 lines
46 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 <unordered_set>
#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 = 860.0f; // roomier — makes space for the per-row "open folder" button
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";
// When an external wallet is open in place, the active file is its per-target link name; that row
// is the external wallet whose path hashes to it. Precompute per row so the sort stays cheap.
const bool linkMode = isLinkName(active);
auto rowIsActive = [&](const WalletRow& row) {
return linkMode ? (!row.inDatadir && linkNameFor(row.canonPath) == active)
: (row.inDatadir && row.fileName == active);
};
std::vector<char> rowActive(s_rows.size(), 0);
for (std::size_t j = 0; j < s_rows.size(); ++j) rowActive[j] = rowIsActive(s_rows[j]) ? 1 : 0;
// ---- 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: a segmented key picker + an arrow that flips ascending/descending ----
{
ImGui::AlignTextToFramePadding();
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_sort_by"));
ImGui::SameLine();
ImFont* segFont = Type().caption();
const char* segLabels[] = { TR("wallets_sort_created"), TR("wallets_sort_addresses"),
TR("wallets_sort_txs"), TR("wallets_sort_size") };
float maxLW = 0.0f;
for (const char* l : segLabels)
maxLW = std::max(maxLW, segFont->CalcTextSizeA(segFont->LegacySize, FLT_MAX, 0, l).x);
const float segH = ImGui::GetFrameHeight();
const float segTW = (maxLW + 20.0f * dp) * 4.0f;
const ImVec2 segOrigin = ImGui::GetCursorScreenPos();
const int clk = material::SegmentedControl(ImGui::GetWindowDrawList(), segOrigin, segTW, segH,
segLabels, 4, s_sortMode, segFont, "##walletSortSeg", dp);
if (clk >= 0) s_sortMode = clk;
// SegmentedControl is draw-list based and doesn't move the layout cursor, so place the
// direction toggle explicitly just to its right on the same baseline (a real item advances
// the cursor past this row for the list below). A circular IconButton (square size →
// bgRounding = radius) with the arrow glyph centered.
ImGui::SetCursorScreenPos(ImVec2(segOrigin.x + segTW + Layout::spacingSm(), segOrigin.y));
IconButtonStyle dirStyle;
dirStyle.restBg = WithAlpha(OnSurface(), 24);
dirStyle.hoverBg = WithAlpha(OnSurface(), 44);
dirStyle.color = OnSurfaceMedium();
dirStyle.tooltip = TR(s_sortDesc ? "wallets_sort_desc" : "wallets_sort_asc");
if (IconButton("##walletSortDir", s_sortDesc ? ICON_MD_ARROW_DOWNWARD : ICON_MD_ARROW_UPWARD,
Type().iconSmall(), ImVec2(segH, segH), dirStyle))
s_sortDesc = !s_sortDesc;
}
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) {
const bool aa = rowActive[a] != 0, ba = rowActive[b] != 0;
if (aa != ba) return aa; // the active wallet is always pinned to the top
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;
};
// The containing sub-directory as its last 1-2 components (e.g. "…/Backups/2021"), so several
// same-named wallet.dat files surfaced by the recursive scan are distinguishable at a glance.
auto shortSubdir = [](std::string d) {
while (d.size() > 1 && (d.back() == '/' || d.back() == '\\')) d.pop_back();
const auto p2 = d.find_last_of("/\\");
if (p2 == std::string::npos) return d; // no separator: whole thing
std::string leaf = d.substr(p2 + 1);
const auto p1 = (p2 > 0) ? d.find_last_of("/\\", p2 - 1) : std::string::npos;
std::string parent = (p1 == std::string::npos) ? d.substr(0, p2) : d.substr(p1 + 1, p2 - p1 - 1);
std::string out = parent.empty() ? leaf : (parent + "/" + leaf);
return (p1 != std::string::npos && p1 > 0) ? ("\xE2\x80\xA6/" + out) : out; // "…/" if deeper
};
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 = rowActive[i] != 0;
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 + folder-button slots on the right — reserve room so text truncates first.
const float btnW = 96.0f * dp, btnH = ImGui::GetFrameHeight();
const float btnX = rMax.x - padX - btnW; // Open button / Active chip zone
const float folderW = btnH; // circular "open folder" button
const float folderX = btnX - folderW - Layout::spacingSm();
const float textR = folderX - 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;
if (!r.inDatadir) ms = shortSubdir(r.dir) + dot; // which sub-directory this wallet is in
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: circular "open folder" button, then the Active chip (current) or an Open button.
// External wallets Open IN PLACE (link, no copy); datadir wallets switch directly.
ImGui::PushID(static_cast<int>(i));
{
ImGui::SetCursorScreenPos(ImVec2(folderX, midY - folderW * 0.5f));
IconButtonStyle fs;
fs.restBg = WithAlpha(OnSurface(), 20);
fs.hoverBg = WithAlpha(OnSurface(), 44);
fs.color = OnSurfaceMedium();
fs.tooltip = TR("wallets_open_folder");
if (IconButton("##walletFolder", ICON_MD_FOLDER_OPEN, Type().iconSmall(), ImVec2(folderW, folderW), fs))
util::Platform::openFolder(r.dir);
}
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 {
ImGui::SetCursorScreenPos(ImVec2(btnX, midY - btnH * 0.5f));
if (StyledButton(TR("wallets_open"), ImVec2(btnW, btnH))) {
if (r.inDatadir) { app->switchToWallet(r.fileName); s_open = false; }
else { openInPlace(app, r); }
}
if (!r.inDatadir && ImGui::IsItemHovered()) Tooltip("%s", TR("wallets_open_inplace_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() || isLinkName(name)) { // reserve the in-place-link prefix
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;
std::string canonPath; // symlink-resolved, absolute path — the STABLE identity of the physical
// file (two paths reaching the same wallet share it), used for the
// in-place link name + to de-dup rows. Computed once at scan time.
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";
}
// A reserved bare-filename PREFIX for the datadir links that let out-of-datadir wallets open in place.
// Each external wallet gets its own STABLE link name derived from its path — so switching between two
// of them is a real -wallet=<different name> switch (not a no-op on one shared name), and the wallet
// index tracks each separately (correct per-wallet rescan + cached data). Hidden from the list.
static constexpr const char* kLinkPrefix = "wallet-ip-";
static bool isLinkName(const std::string& n) { return n.rfind(kLinkPrefix, 0) == 0; }
// Stable per-target bare link name, e.g. "wallet-ip-1a2b3c4d.dat" (FNV-1a of the absolute path — a
// deterministic, cross-platform, cross-run hash, unlike std::hash). Feed it a canonicalOf() path so the
// SAME physical wallet always maps to the same name regardless of which path reached it.
static std::string linkNameFor(const std::string& absPath) {
std::uint64_t h = 1469598103934665603ULL;
for (unsigned char c : absPath) { h ^= c; h *= 1099511628211ULL; }
char buf[40];
std::snprintf(buf, sizeof(buf), "%s%08x.dat", kLinkPrefix,
static_cast<unsigned>(h ^ (h >> 32)));
return buf;
}
// Symlink-resolved, absolute path — the stable identity of a physical wallet file. Two different path
// strings that reach the same file (e.g. a symlinked mount /mnt/usb vs the real /media/usb) collapse to
// one value, so they share a single in-place link name and de-dup to one row. fs::canonical resolves
// symlinks (the file exists at scan time); on failure fall back to a lexical-absolute path.
static std::string canonicalOf(const std::filesystem::path& p) {
namespace fs = std::filesystem;
std::error_code ec;
fs::path c = fs::canonical(p, ec);
if (ec) c = fs::weakly_canonical(p, ec);
return c.empty() ? p.string() : c.string();
}
// Open an out-of-datadir wallet IN PLACE. The daemon only loads a bare filename from its datadir, so
// link the real file in under its per-target name and switch to that. On Linux/macOS prefer a SYMLINK
// (no privileges, spans volumes so a wallet on a USB / other partition works, and it's visibly a
// pointer — not a duplicate); on Windows prefer a HARD LINK (a symlink there needs admin / Developer
// Mode), falling back to a symlink. Either way we NEVER copy (that would fork the wallet) and NEVER
// delete a real file — only our own link.
static void openInPlace(App* app, const WalletRow& r) {
namespace fs = std::filesystem;
std::error_code ec;
const std::string datadir = util::Platform::getDragonXDataDir();
fs::create_directories(datadir, ec);
const std::string src = r.dir + "/" + r.fileName;
// The row could be stale (file moved/deleted between the scan and this click). Don't link a ghost:
// on Linux/macOS create_symlink to a missing target would happily succeed (a dangling link), we'd
// switch to it, and the daemon guard would silently fall back to wallet.dat — "opened X, got the
// default". Refresh the list instead so the vanished row drops out.
if (!fs::exists(src, ec)) { scan(); return; }
// canonPath (symlinks resolved) is the wallet's stable identity: hash it so the same file reached
// via any path maps to ONE link name, and point the link straight at the resolved real file.
const std::string target = r.canonPath.empty() ? canonicalOf(src) : r.canonPath;
const std::string linkName = linkNameFor(target);
const std::string linkPath = datadir + "/" + linkName;
// Reconcile whatever already holds our reserved name. We only ever reclaim a link of OURS — never
// destroy an unknown file's unique data. is_symlink uses lstat, so it also catches a DANGLING prior
// symlink (source moved) that fs::exists — which follows the link — would miss and that
// create_symlink would then trip over ("file exists").
if (fs::is_symlink(linkPath, ec)) {
fs::remove(linkPath, ec); // our prior symlink (maybe dangling) — replace it
} else if (fs::exists(linkPath, ec)) {
if (fs::equivalent(linkPath, src, ec)) { // already a valid hard link to THIS wallet
app->switchToWallet(linkName); s_open = false; return; // → reuse as-is (same inode)
}
// A DIFFERENT file holds our reserved name. scan()/create() forbid real wallets at this prefix,
// so it's a stale orphan (a hard link whose source moved, or a copy left by a datadir that was
// migrated across filesystems) — NOT the current wallet; reusing it would load the wrong
// balance. Reclaim the name only if the file is provably redundant (another hard link still
// holds the data); otherwise refuse rather than risk destroying something unique.
if (fs::hard_link_count(linkPath, ec) > 1) {
fs::remove(linkPath, ec);
} else {
Notifications::instance().error(TR("wallets_open_failed"));
return;
}
}
std::error_code lec;
#ifdef _WIN32
fs::create_hard_link(target, linkPath, lec); // 1) hard link — no privileges, same volume
if (lec) { lec.clear(); fs::create_symlink(target, linkPath, lec); } // 2) symlink — cross-volume (needs admin / Developer Mode)
#else
fs::create_symlink(target, linkPath, lec); // 1) symlink — no privileges, spans volumes
if (lec) { lec.clear(); fs::create_hard_link(target, linkPath, lec); } // 2) hard link — same-volume fallback
#endif
if (lec) { Notifications::instance().error(TR("wallets_open_failed")); return; } // no copy fallback (would fork the wallet)
app->switchToWallet(linkName); // distinct per wallet → the index tracks rescan/cache correctly
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
};
std::unordered_set<std::string> seenCanon; // one row per physical wallet. The datadir is scanned
// first, so a datadir wallet wins over the same file
// reached via an external (possibly symlinked) path.
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 || isLinkName(name)) return; } // datadir: wallet-prefixed, hide in-place links
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.canonPath = canonicalOf(p); // symlink-resolved identity (see below)
if (!seenCanon.insert(r.canonPath).second) return; // same physical wallet already listed via another path
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