The full UI sweep (dev builds only) ran its demo surfaces against the
real profile. Its teardown deleted wallet-{cold,trading,mining,donations,
2023,payroll}.dat from the real datadir, it created zero-filled wallet
files and picker-demo/extra-wallets folders, its demo wallet-index upsert
re-minted the active wallet's scopeId (hiding "w:"-scoped contacts), the
Contacts tab could re-attach legacy-scoped contacts to such a throwaway
id in addressbook.json, and it persisted the demo active wallet file and
Mining pool worker.
- Preflight: a full sweep is refused (toast + log line) while a real
workflow is in flight: a pending seed_migration_* setting or open
migrate dialog, a wallet switch, an encryption/import workflow, an
encrypt/PIN/lock operation, the seed-backup dialog, or a bootstrap.
The DRAGONX_FULL_SWEEP hook then exits as for an empty catalog.
- Persistence latches: AddressBook (snapshot on the first sweepBegin,
save/load latched while a demo-contacts surface is shown), WalletIndex
and Settings (active wallet file, contacts view mode, pool URL/worker)
for the whole sweep. Settings::sweepEnd() is the last statement of
clearDemoWalletData and flushes once only if a save was suppressed; a
quit mid-sweep writes nothing to the profile (the 0700 sandbox, with
zero-filled demo files only, may stay in the temp dir).
- Scope ids: during a full sweep activeWalletScopeId() returns the
persisted id or "" and never mints one, so the Contacts tab cannot
move legacy-scoped contacts to a throwaway id. The only address-book
write a sweep can still cause is the one-time legacy re-attach to the
wallet's persisted id, which opening Contacts does anyway.
- Sandbox: demo wallet files and folders go to a fresh random 0700 dir
under the temp dir (exclusive create, a pre-existing path or symlink
is refused), removed at sweep end. The teardown that deleted those six
wallet files is gone.
- Cleanup: the migrate-to-seed state, wizard phase, demo chat
conversations and a fabricated demo chat identity are reset at sweep
end; the seed-backup surface always shows the demo phrase and a real
z_exportmnemonic result is dropped while the sweep runs.
- i18n: one new key (screenshot_sweep_refused) in i18n.cpp and the 8
language files.
Tests: the address book snapshots only on the first sweepBegin, keeps
addressbook.json byte-identical while latched and restores the real
book; the wallet index keeps wallets.json and the real scope id, also
with the background writer attached; Settings restores the latched
values and flushes once only when a save was suppressed; the sandbox
helper refuses a pre-existing path or symlink and removes only a
prefixed dir, never a symlink's target.
Release note: v2.0.x UI sweeps could delete wallet-{cold,trading,mining,
donations,2023,payroll}.dat from the data directory and re-scope
contacts; this cannot be undone automatically.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
902 lines
57 KiB
C++
902 lines
57 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 <chrono>
|
|
#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 "../../util/wallet_folder_scan.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 {
|
|
|
|
namespace wallets_dialog_detail {
|
|
// The last external-folder walk (WalletsDialog::s_ext). At namespace scope rather than nested: a static inline
|
|
// member whose nested type has default member initializers is ill-formed inside the enclosing class.
|
|
struct ExtCache {
|
|
bool valid = false;
|
|
std::string key;
|
|
std::vector<util::ScannedWallet> rows;
|
|
std::chrono::steady_clock::time_point at{};
|
|
};
|
|
} // namespace wallets_dialog_detail
|
|
|
|
class WalletsDialog {
|
|
public:
|
|
static void show(App* app) {
|
|
s_open = true;
|
|
s_app = app;
|
|
s_needScan = true;
|
|
}
|
|
|
|
static void hide() { s_open = false; }
|
|
|
|
// UI sweep ONLY: list and create wallets in this directory (the sweep's sandbox) instead of the real
|
|
// datadir. Empty (the default) means the real datadir.
|
|
static void setSweepDataDir(std::string dir) { s_sweepDataDir = std::move(dir); }
|
|
static std::string dataDir() {
|
|
return s_sweepDataDir.empty() ? util::Platform::getDragonXDataDir() : s_sweepDataDir;
|
|
}
|
|
|
|
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_forceScan); s_needScan = false; s_forceScan = false; }
|
|
pumpScan(); // merge a finished folder walk HERE, before anything below takes references into s_rows
|
|
// Work on other threads (folder walk, wallet-file probes): keep frames coming (~10 fps, visible window)
|
|
// so rows and badges land without the user moving the mouse. Only for kBgFrameWindowSec after the last
|
|
// walk/probe started: one stuck on a dead network mount must not keep the dialog rendering forever (a
|
|
// late result is still merged on the next frame any input produces).
|
|
constexpr double kBgFrameWindowSec = 30.0;
|
|
if ((s_scanning || (s_probe && s_probe->pending.load(std::memory_order_relaxed) > 0)) &&
|
|
std::chrono::duration<double>(std::chrono::steady_clock::now() - s_bgWorkSince).count() < kBgFrameWindowSec)
|
|
RequestProgressFrame();
|
|
|
|
// ---- 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();
|
|
// Size the list to the ACTUAL wallet count so a few wallets sit tight against the controls below
|
|
// instead of stranding ~200px of empty rows before the create/scan prompts; cap at kMaxVisibleRows
|
|
// (more than that scrolls within the cap). An empty list keeps two rows of height so the centered
|
|
// empty-state hint (drawn inside the list child) still has room to show.
|
|
const int numWallets = (int)s_rows.size();
|
|
const int visRows = numWallets <= 0 ? 2 : std::min(numWallets, 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 int numScanFolders = app ? (int)app->walletIndex().extraFolders().size() : 0;
|
|
const float foldersH = numScanFolders > 0
|
|
? (capRow + (float)numScanFolders * ctrlRow + Layout::spacingXs()) // "Scanned folders:" + one row each
|
|
: 0.0f;
|
|
const float belowH = 4.0f * Layout::spacingSm()
|
|
+ (capRow + ctrlRow) // Create label + input row
|
|
+ ctrlRow // full-width "scan folder" button
|
|
+ foldersH // scanned-folders manager
|
|
+ (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 * dp; // 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) {
|
|
// Fill the height left above the pinned controls, but clip on a CLEAN row boundary:
|
|
// floor the available space to a whole number of card strides (walRowH + cardGap, the
|
|
// per-card advance from the loop) so the list never ends mid-row. Keep the same bottom
|
|
// pad as the uncapped listH so the last visible card stays off the clip edge.
|
|
const float stride = walRowH + cardGap;
|
|
const float avail = ImGui::GetContentRegionAvail().y - belowH;
|
|
// N rows occupy N*walRowH + (N-1)*cardGap = N*stride - cardGap; solve for the most whole
|
|
// rows that fit (a non-negative cast truncates toward zero = floor here), clamped to at
|
|
// least one so a tiny viewport still shows a row.
|
|
int nRows = stride > 0.0f ? (int)((avail + cardGap) / stride) : 1;
|
|
if (nRows < 1) nRows = 1;
|
|
listHFit = (float)nRows * stride - cardGap + Layout::spacingSm();
|
|
}
|
|
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 * dp; g.fillAlpha = hov ? 40 : 26; g.borderAlpha = 45;
|
|
DrawGlassPanel(dl, rMin, rMax, g);
|
|
if (isCurrent) {
|
|
dl->AddRectFilled(rMin, rMax, WithAlpha(Success(), 16), 10.0f * dp);
|
|
dl->AddRect(rMin, rMax, WithAlpha(Success(), 130), 10.0f * dp, 0, 1.6f * dp);
|
|
} else if (hov) {
|
|
dl->AddRect(rMin, rMax, WithAlpha(OnSurface(), 70), 10.0f * dp, 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;
|
|
// Seed-phrase vs legacy. The offline probe reads the hdchain record's fMnemonicSeed flag
|
|
// straight off disk (pres.mnemonic: 1 = BIP39 seed phrase, 2 = HD/legacy with no phrase,
|
|
// 0 = couldn't tell). That is the SAME flag the daemon's IsMnemonicSeed()/z_exportmnemonic
|
|
// consult, so a definitive read is authoritative and takes precedence. The runtime badge
|
|
// (activeWalletSeedBadge, reset only on disconnect) is used ONLY when the offline probe
|
|
// couldn't decide — it must NEVER override a definitive on-disk read, or a stale
|
|
// HasMnemonic carried from a previously-active mnemonic wallet mislabels a legacy wallet as
|
|
// a seed-phrase wallet. seed uses the same 1/2/0 encoding.
|
|
int seed = 0;
|
|
if (pres.probed && pres.mnemonic != 0)
|
|
seed = pres.mnemonic; // definitive on-disk flag wins
|
|
else if (rowActive[i] && app->activeWalletSeedBadge() != 0)
|
|
seed = app->activeWalletSeedBadge(); // runtime fallback (active row only)
|
|
else if (pres.probed && pres.complete && !pres.hdSeed)
|
|
seed = 2; // no HD records at all → no phrase
|
|
const bool bSeed = (seed == 1);
|
|
const bool bLegacy = (seed == 2);
|
|
// seed==0 splits by what the probe DID learn: if it saw HD records we know it's an HD
|
|
// wallet (we just couldn't read the phrase flag — e.g. the tier-1 byte-scan fallback), so
|
|
// label it "HD wallet" rather than a bare "?"; only a scan that couldn't even establish
|
|
// that (incomplete, no HD marker yet) stays "Unknown". Both yield to the Lock badge when
|
|
// encrypted, so an encrypted-but-unclassified row shows just the lock.
|
|
const bool bHd = (seed == 0) && pres.probed && pres.hdSeed && !pres.encrypted;
|
|
const bool bUnknown = (seed == 0) && pres.probed && !pres.hdSeed && !pres.encrypted;
|
|
struct Badge { const char* glyph; ImU32 col; const char* label; const char* tip; };
|
|
Badge bl[4]; int nb = 0; // seed/legacy/hd/unknown are mutually exclusive → at most one + lock
|
|
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 (bHd) bl[nb++] = { ICON_MD_ACCOUNT_TREE, WithAlpha(OnSurfaceMedium(), 210), TR("wallets_badge_hd_short"), TR("wallets_badge_hd") };
|
|
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 (TactileButton(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));
|
|
}
|
|
// Empty-state nudge: the list is now sized to the actual wallet count, so a populated list has
|
|
// no room to spare and this draws nothing (the cards butt up against the controls below). It
|
|
// fires only when the list is EMPTY (the reserved rows leave room): a subtle centered hint (a
|
|
// folder glyph + one line) instead of a blank box; purely decorative — the actions live below.
|
|
{
|
|
const float remainY = ImGui::GetContentRegionAvail().y;
|
|
if (remainY > walRowH * 1.6f) {
|
|
ImDrawList* wdl = ImGui::GetWindowDrawList();
|
|
ImFont* hIco = Type().iconLarge();
|
|
ImFont* hTxt = Type().caption();
|
|
const char* icon = ICON_MD_CREATE_NEW_FOLDER;
|
|
const char* hint = TR("wallets_empty_hint");
|
|
const ImU32 col = OnSurfaceDisabled();
|
|
const ImVec2 is = hIco->CalcTextSizeA(hIco->LegacySize, FLT_MAX, 0, icon);
|
|
const ImVec2 ts = hTxt->CalcTextSizeA(hTxt->LegacySize, FLT_MAX, 0, hint);
|
|
const float gap = Layout::spacingXs();
|
|
const ImVec2 o = ImGui::GetCursorScreenPos();
|
|
const float cx = o.x + ImGui::GetContentRegionAvail().x * 0.5f;
|
|
const float top = o.y + (remainY - (is.y + gap + ts.y)) * 0.5f;
|
|
wdl->AddText(hIco, hIco->LegacySize, ImVec2(cx - is.x * 0.5f, top), col, icon);
|
|
wdl->AddText(hTxt, hTxt->LegacySize, ImVec2(cx - ts.x * 0.5f, top + is.y + gap), col, hint);
|
|
}
|
|
}
|
|
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 (TactileButton(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(dataDir() + "/" + 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 (TactileButton(TR("wallets_scan_folder"), ImVec2(listW, 0))) {
|
|
FolderPicker::open(dataDir(), [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"));
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---- Manage scanned folders: list each user-added folder with a control to stop scanning it.
|
|
// (The datadir is always scanned and isn't listed here — only the folders the user added.) -----
|
|
{
|
|
const auto& scanFolders = app->walletIndex().extraFolders();
|
|
if (!scanFolders.empty()) {
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_scanned_folders"));
|
|
if (s_scanning) {
|
|
ImGui::SameLine();
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("wallets_scanning"));
|
|
}
|
|
ImFont* pf = Type().caption();
|
|
const float bh2 = ImGui::GetFrameHeight();
|
|
// Front-elide a long path so its identifying tail (the leaf folder) stays visible.
|
|
auto elideFront = [&](std::string s, float maxW) {
|
|
auto w = [&](const std::string& t){ return pf->CalcTextSizeA(pf->LegacySize, FLT_MAX, 0, t.c_str()).x; };
|
|
if (w(s) <= maxW) return s;
|
|
while (s.size() > 1 && w("\xE2\x80\xA6" + s) > maxW) {
|
|
s.erase(s.begin());
|
|
while (!s.empty() && (static_cast<unsigned char>(s.front()) & 0xC0) == 0x80) s.erase(s.begin());
|
|
}
|
|
return "\xE2\x80\xA6" + s;
|
|
};
|
|
std::string toRemove;
|
|
for (const auto& folder : scanFolders) {
|
|
ImGui::PushID(folder.c_str());
|
|
IconButtonStyle rm;
|
|
rm.color = OnSurfaceMedium();
|
|
rm.hoverColor = Error();
|
|
rm.hoverBg = StateHover();
|
|
rm.bgRounding = 4.0f * dp;
|
|
rm.tooltip = TR("wallets_remove_folder");
|
|
if (IconButton("##rmScanFolder", ICON_MD_CLOSE, Type().iconSmall(), ImVec2(bh2, bh2), rm))
|
|
toRemove = folder;
|
|
ImGui::SameLine(0, Layout::spacingSm());
|
|
ImGui::AlignTextToFramePadding();
|
|
const float textW = listW - bh2 - 2.0f * Layout::spacingSm();
|
|
Type().textColored(TypeStyle::Caption, OnSurface(), elideFront(folder, textW).c_str());
|
|
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", folder.c_str());
|
|
ImGui::PopID();
|
|
}
|
|
if (!toRemove.empty()) {
|
|
app->walletIndex().removeExtraFolder(toRemove);
|
|
app->walletIndex().save();
|
|
s_needScan = true; // re-scan so wallets from the dropped folder disappear
|
|
}
|
|
}
|
|
}
|
|
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
// ---- 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;
|
|
s_forceScan = true; // explicit Refresh bypasses the 60 s folder-walk cache
|
|
}
|
|
}
|
|
ImGui::SameLine();
|
|
if (TactileButton(TR("wallets_reveal"), ImVec2(150.0f * dp, 0)))
|
|
util::Platform::openFolder(dataDir());
|
|
ImGui::SameLine();
|
|
if (TactileButton(TR("close"), ImVec2(110.0f * dp, 0))) s_open = false;
|
|
|
|
EndOverlayDialog();
|
|
}
|
|
}
|
|
|
|
private:
|
|
using WalletRow = util::ScannedWallet; // fileName, dir, canonPath, inDatadir, sizeBytes (see the header)
|
|
|
|
// 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
|
|
int mnemonic = 0; // hdchain fMnemonicSeed flag: 0 unknown, 1 seed-phrase, 2 no phrase
|
|
};
|
|
struct ProbeBatch {
|
|
std::mutex mtx;
|
|
std::vector<ProbeResult> results; // index-aligned with s_rows (rows are only appended after scan())
|
|
std::size_t budget = 768u * 1024u * 1024u; // shared read budget of every probe on this batch (under mtx)
|
|
std::atomic<bool> cancel{false};
|
|
std::atomic<int> pending{0}; // probe threads still running on this batch
|
|
};
|
|
// One external-folder walk. The worker fills `rows`, then publishes `ready` (release); render() merges it on
|
|
// the UI thread (pumpScan). Owned via shared_ptr by the detached worker, so it may outlive the dialog.
|
|
struct ScanBatch {
|
|
std::mutex mtx;
|
|
std::vector<util::ScannedWallet> rows;
|
|
std::atomic<bool> ready{false};
|
|
std::atomic<bool> cancel{false};
|
|
std::string key; // util::externalScanKey(folders) at launch
|
|
};
|
|
using ExtCache = wallets_dialog_detail::ExtCache;
|
|
|
|
// 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.
|
|
// Single source of truth lives in util/wallet_file_probe.h (shared with the offline enumeration helper).
|
|
static constexpr const char* kLinkPrefix = util::kInPlaceLinkPrefix;
|
|
static bool isLinkName(const std::string& n) { return util::isInPlaceLinkName(n); }
|
|
|
|
// 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 = dataDir();
|
|
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 — but not here: we are inside
|
|
// render()'s row loop, which holds `const WalletRow& r` and iterates s_order sized for the current
|
|
// rows, so a rebuild would leave both dangling (out-of-bounds). Flag it; the next frame rescans,
|
|
// forced so the folder-walk cache can't resurrect the ghost row.
|
|
if (!fs::exists(src, ec)) { s_needScan = true; s_forceScan = true; 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;
|
|
}
|
|
|
|
// Read light facts off each wallet.dat (encryption / seed flags / counts) on a background thread so a big
|
|
// file never stalls the UI. Captures only its own heap batch + path copies (never s_rows), so it is safe to
|
|
// outlive a re-scan or app shutdown. Results land index-aligned in the batch render() reads under the mutex.
|
|
static void 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); the
|
|
// batch's shared budget (768 MB, as before, across ALL probe threads of the batch) bounds total I/O,
|
|
// charged by bytes actually read.
|
|
constexpr std::size_t kPerFile = 256u * 1024u * 1024u;
|
|
for (const auto& t : targets) {
|
|
if (batch->cancel.load()) return;
|
|
std::size_t cap = 0;
|
|
{ std::lock_guard<std::mutex> lk(batch->mtx); cap = std::min(batch->budget, kPerFile); }
|
|
ProbeResult res;
|
|
std::size_t used = 0;
|
|
if (cap > 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.
|
|
const auto bt = util::parseWalletBtree(t.first, cap);
|
|
if (bt.parsed && bt.complete) {
|
|
res = ProbeResult{ true, true, bt.encrypted, bt.hdSeed, true, bt.addresses(), bt.txCount, bt.createdEpoch };
|
|
res.mnemonic = bt.mnemonicSeed; // read straight off the hdchain record
|
|
used = bt.bytesRead;
|
|
} else {
|
|
const auto pr = util::probeWalletFile(t.first, cap);
|
|
res = ProbeResult{ pr.isBerkeleyDB, pr.scanComplete, pr.encrypted, pr.hdSeed };
|
|
// A cap-truncated btree walk still yields DEFINITIVE positives (a found marker is
|
|
// authoritative even when the scan didn't finish) — notably the fMnemonicSeed flag;
|
|
// otherwise a large wallet probed after the budget is spent loses its seed/legacy class.
|
|
if (bt.mnemonicSeed != 0) res.mnemonic = bt.mnemonicSeed;
|
|
if (bt.hdSeed) res.hdSeed = true;
|
|
if (bt.encrypted) res.encrypted = true;
|
|
used = std::max(bt.bytesRead, pr.bytesRead);
|
|
}
|
|
}
|
|
std::lock_guard<std::mutex> lk(batch->mtx);
|
|
batch->budget -= std::min(batch->budget, used);
|
|
if (t.second < batch->results.size()) batch->results[t.second] = res;
|
|
}
|
|
}
|
|
|
|
// Probe s_rows[from..] into `batch` (detached; synchronous during the offline screenshot sweep so the
|
|
// captured frame is populated). Rows are only APPENDED after a scan(), so earlier indices stay valid.
|
|
static void startProbe(const std::shared_ptr<ProbeBatch>& batch, std::size_t from) {
|
|
std::vector<std::pair<std::string, std::size_t>> targets;
|
|
for (std::size_t i = from; i < s_rows.size(); ++i)
|
|
targets.emplace_back(s_rows[i].dir + "/" + s_rows[i].fileName, i);
|
|
if (targets.empty()) return;
|
|
auto job = [](std::shared_ptr<ProbeBatch> b, std::vector<std::pair<std::string, std::size_t>> t) {
|
|
runProbe(b, std::move(t));
|
|
b->pending.fetch_sub(1);
|
|
};
|
|
batch->pending.fetch_add(1);
|
|
s_bgWorkSince = std::chrono::steady_clock::now();
|
|
if (s_app && s_app->isScreenshotSweeping()) job(batch, std::move(targets));
|
|
else std::thread(job, batch, std::move(targets)).detach();
|
|
}
|
|
|
|
// Append external rows (fresh or cached) after the datadir rows, de-duplicated against what is listed now
|
|
// (a cached walk may predate a new datadir wallet), and probe just the new ones.
|
|
static void appendExternal(std::vector<util::ScannedWallet> ext, std::unordered_set<std::string>& seen) {
|
|
const std::size_t from = s_rows.size();
|
|
for (auto& w : ext)
|
|
if (seen.insert(w.canonPath).second) s_rows.push_back(std::move(w));
|
|
if (s_rows.size() == from || !s_probe) return;
|
|
{ std::lock_guard<std::mutex> lk(s_probe->mtx); s_probe->results.resize(s_rows.size()); }
|
|
startProbe(s_probe, from);
|
|
}
|
|
|
|
// UI thread, top of render(): fold a finished walk into s_rows. All s_rows/s_probe changes happen at frame
|
|
// start (here or in scan()), never while render() holds a reference into s_rows.
|
|
static void pumpScan() {
|
|
if (!s_scan || !s_scan->ready.load(std::memory_order_acquire)) return;
|
|
std::shared_ptr<ScanBatch> sb = std::move(s_scan);
|
|
s_scan.reset();
|
|
s_scanning = false;
|
|
std::vector<util::ScannedWallet> rows;
|
|
{ std::lock_guard<std::mutex> lk(sb->mtx); rows = std::move(sb->rows); }
|
|
s_ext.valid = true;
|
|
s_ext.key = sb->key;
|
|
s_ext.rows = rows;
|
|
s_ext.at = std::chrono::steady_clock::now();
|
|
std::unordered_set<std::string> seen;
|
|
for (const auto& r : s_rows) seen.insert(r.canonPath);
|
|
appendExternal(std::move(rows), seen);
|
|
}
|
|
|
|
// Rebuild the list. The datadir pass (top-level wallet*.dat, tens of entries) stays synchronous so a
|
|
// just-created wallet shows on the next open. The RECURSIVE walk of user-added folders (bounded: depth 8,
|
|
// 200 hits, 40,000 entries per folder; never follows directory symlinks) runs on a detached worker and is
|
|
// merged by pumpScan(); a result younger than 60 s for the same folder list is reused without touching the
|
|
// disk (Refresh and a stale-row Open force a walk; add/remove folder change the key). Supersedes any
|
|
// in-flight walk/probe: those only touch their own heap batches, never s_rows.
|
|
static void scan(bool force = false) {
|
|
constexpr double kExtCacheTtlSec = 60.0;
|
|
if (s_probe) s_probe->cancel.store(true);
|
|
if (s_scan) s_scan->cancel.store(true);
|
|
s_scan.reset();
|
|
s_scanning = false;
|
|
|
|
std::unordered_set<std::string> seen; // one row per physical wallet; the datadir is first, so it wins
|
|
s_rows = util::scanDatadirWallets(dataDir(), seen);
|
|
s_probe = std::make_shared<ProbeBatch>();
|
|
s_probe->results.resize(s_rows.size());
|
|
startProbe(s_probe, 0); // datadir badges start filling in at once
|
|
|
|
std::vector<std::string> folders;
|
|
if (s_app) folders = s_app->walletIndex().extraFolders();
|
|
if (folders.empty()) { s_ext = ExtCache{}; return; }
|
|
|
|
const std::string key = util::externalScanKey(folders);
|
|
const double age = s_ext.valid
|
|
? std::chrono::duration<double>(std::chrono::steady_clock::now() - s_ext.at).count() : -1.0;
|
|
if (util::externalScanCacheUsable(s_ext.valid, s_ext.key, key, age, kExtCacheTtlSec, force)) {
|
|
appendExternal(s_ext.rows, seen); // no disk walk
|
|
return;
|
|
}
|
|
|
|
auto sb = std::make_shared<ScanBatch>();
|
|
sb->key = key;
|
|
s_scan = sb;
|
|
s_scanning = true;
|
|
s_bgWorkSince = std::chrono::steady_clock::now();
|
|
auto work = [](std::shared_ptr<ScanBatch> b, std::vector<std::string> fl,
|
|
std::unordered_set<std::string> seenCopy, std::size_t already) {
|
|
util::WalletScanLimits lim; // 8 / 200 / 40000, as before
|
|
auto rows = util::scanExternalWalletFolders(fl, lim, seenCopy, already, &b->cancel);
|
|
if (b->cancel.load()) return; // superseded: publish nothing
|
|
{ std::lock_guard<std::mutex> lk(b->mtx); b->rows = std::move(rows); }
|
|
b->ready.store(true, std::memory_order_release);
|
|
};
|
|
const std::size_t listed = s_rows.size();
|
|
if (s_app && s_app->isScreenshotSweeping()) {
|
|
work(sb, std::move(folders), seen, listed); // offline sweep: synchronous, frame fully populated
|
|
pumpScan();
|
|
} else {
|
|
std::thread(work, sb, std::move(folders), std::move(seen), listed).detach();
|
|
}
|
|
}
|
|
|
|
static inline bool s_open = false;
|
|
static inline App* s_app = nullptr;
|
|
static inline std::string s_sweepDataDir; // UI sweep sandbox datadir ("" = the real one)
|
|
static inline bool s_needScan = false;
|
|
static inline bool s_forceScan = false; // next scan bypasses the folder-walk cache
|
|
static inline bool s_scanning = false; // an external folder walk is in flight
|
|
static inline std::shared_ptr<ScanBatch> s_scan; // that walk (null when none)
|
|
static inline ExtCache s_ext; // last walk result (60 s TTL, keyed by folder list)
|
|
static inline std::chrono::steady_clock::time_point s_bgWorkSince{}; // last walk/probe start (frame wake-ups)
|
|
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
|