perf(wallets): walk added wallet folders off the UI thread
Every open of the Wallets dialog, Refresh, folder add or remove, and Open on a stale row walked each user-added folder recursively on the UI thread before the dialog drew: up to 40,000 entries per folder, roughly 50-150 ms warm on Linux and seconds on a cold disk, a network share or Windows. The datadir listing (a few top-level files) stays synchronous, so a wallet just created shows at once. The recursive walk runs on a detached worker with the same depth, hit and entry limits; its rows are merged at the start of a later frame and only the new rows are probed. - The dialog opens at once with the datadir rows. The external rows arrive when the walk ends, with "Scanning folders..." beside the scanned-folders caption, and the card grows once. - Reopening within 60 s with the same folders reuses the last walk without touching the disk, so its rows and sizes can be up to a minute stale: a wallet copied into a folder in that minute needs Refresh. Refresh and Open on a stale row always walk again. - While a walk or a wallet probe runs (up to 30 s) the dialog keeps frames coming, so rows and badges appear without moving the mouse. - The 200-row cap now holds across all folders; each extra folder could add one row past it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2304,6 +2304,7 @@
|
||||
"wallets_reveal": "Ordner anzeigen",
|
||||
"wallets_scan_folder": "Einen weiteren Ordner nach Wallets durchsuchen…",
|
||||
"wallets_scanned_folders": "Durchsuchte Ordner:",
|
||||
"wallets_scanning": "Ordner werden durchsucht…",
|
||||
"wallets_sort_addresses": "Adressen",
|
||||
"wallets_sort_asc": "Aufsteigend (ältestes / wenigste / kleinstes zuerst)",
|
||||
"wallets_sort_by": "Sortieren:",
|
||||
|
||||
@@ -2304,6 +2304,7 @@
|
||||
"wallets_reveal": "Mostrar carpeta",
|
||||
"wallets_scan_folder": "Escanear otra carpeta en busca de carteras…",
|
||||
"wallets_scanned_folders": "Carpetas escaneadas:",
|
||||
"wallets_scanning": "Buscando en las carpetas…",
|
||||
"wallets_sort_addresses": "Direcciones",
|
||||
"wallets_sort_asc": "Ascendente (más antiguo / menos / más pequeño primero)",
|
||||
"wallets_sort_by": "Ordenar:",
|
||||
|
||||
@@ -2304,6 +2304,7 @@
|
||||
"wallets_reveal": "Afficher le dossier",
|
||||
"wallets_scan_folder": "Analyser un autre dossier pour des portefeuilles…",
|
||||
"wallets_scanned_folders": "Dossiers analysés :",
|
||||
"wallets_scanning": "Analyse des dossiers…",
|
||||
"wallets_sort_addresses": "Adresses",
|
||||
"wallets_sort_asc": "Croissant (plus ancien / moins / plus petit d'abord)",
|
||||
"wallets_sort_by": "Trier :",
|
||||
|
||||
@@ -2304,6 +2304,7 @@
|
||||
"wallets_reveal": "フォルダを開く",
|
||||
"wallets_scan_folder": "別のフォルダをスキャンしてウォレットを検索…",
|
||||
"wallets_scanned_folders": "スキャン対象フォルダー:",
|
||||
"wallets_scanning": "フォルダをスキャンしています…",
|
||||
"wallets_sort_addresses": "アドレス",
|
||||
"wallets_sort_asc": "昇順(古い/少ない/小さい順)",
|
||||
"wallets_sort_by": "並べ替え:",
|
||||
|
||||
@@ -2304,6 +2304,7 @@
|
||||
"wallets_reveal": "폴더 열기",
|
||||
"wallets_scan_folder": "다른 폴더에서 지갑 검색…",
|
||||
"wallets_scanned_folders": "스캔한 폴더:",
|
||||
"wallets_scanning": "폴더 검색 중…",
|
||||
"wallets_sort_addresses": "주소",
|
||||
"wallets_sort_asc": "오름차순 (오래된/적은/작은 순)",
|
||||
"wallets_sort_by": "정렬:",
|
||||
|
||||
@@ -2304,6 +2304,7 @@
|
||||
"wallets_reveal": "Mostrar pasta",
|
||||
"wallets_scan_folder": "Escanear outra pasta em busca de carteiras…",
|
||||
"wallets_scanned_folders": "Pastas verificadas:",
|
||||
"wallets_scanning": "Procurando nas pastas…",
|
||||
"wallets_sort_addresses": "Endereços",
|
||||
"wallets_sort_asc": "Crescente (mais antigo / menos / menor primeiro)",
|
||||
"wallets_sort_by": "Ordenar:",
|
||||
|
||||
@@ -2304,6 +2304,7 @@
|
||||
"wallets_reveal": "Открыть папку",
|
||||
"wallets_scan_folder": "Сканировать другую папку на наличие кошельков…",
|
||||
"wallets_scanned_folders": "Просканированные папки:",
|
||||
"wallets_scanning": "Поиск в папках…",
|
||||
"wallets_sort_addresses": "Адреса",
|
||||
"wallets_sort_asc": "По возрастанию (сначала старые / меньше / меньший)",
|
||||
"wallets_sort_by": "Сортировка:",
|
||||
|
||||
@@ -2304,6 +2304,7 @@
|
||||
"wallets_reveal": "打开文件夹",
|
||||
"wallets_scan_folder": "扫描其他文件夹以查找钱包…",
|
||||
"wallets_scanned_folders": "已扫描的文件夹:",
|
||||
"wallets_scanning": "正在扫描文件夹…",
|
||||
"wallets_sort_addresses": "地址",
|
||||
"wallets_sort_asc": "升序(最早/最少/最小优先)",
|
||||
"wallets_sort_by": "排序:",
|
||||
|
||||
+182
-144
@@ -11,6 +11,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
@@ -32,6 +33,7 @@
|
||||
#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"
|
||||
@@ -43,6 +45,17 @@
|
||||
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) {
|
||||
@@ -63,7 +76,16 @@ public:
|
||||
const float dp = Layout::dpiScale();
|
||||
ImGuiStyle& style = ImGui::GetStyle();
|
||||
|
||||
if (s_needScan) { scan(); s_needScan = false; }
|
||||
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
|
||||
@@ -497,6 +519,10 @@ public:
|
||||
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.
|
||||
@@ -544,8 +570,10 @@ public:
|
||||
rst.hoverBg = StateHover();
|
||||
rst.bgRounding = 4.0f * dp;
|
||||
rst.tooltip = TR("refresh");
|
||||
if (IconButton("##walletsRefresh", ICON_MD_REFRESH, Type().iconSmall(), ImVec2(bh, bh), rst))
|
||||
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)))
|
||||
@@ -558,15 +586,7 @@ public:
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
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,
|
||||
@@ -585,9 +605,21 @@ private:
|
||||
};
|
||||
struct ProbeBatch {
|
||||
std::mutex mtx;
|
||||
std::vector<ProbeResult> results; // index-aligned with s_rows at the moment of scan()
|
||||
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
|
||||
@@ -656,8 +688,9 @@ private:
|
||||
// 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.
|
||||
if (!fs::exists(src, ec)) { s_needScan = true; return; }
|
||||
// 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;
|
||||
@@ -700,149 +733,154 @@ private:
|
||||
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
|
||||
// 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);
|
||||
}
|
||||
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);
|
||||
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 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;
|
||||
// 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;
|
||||
targets.reserve(s_rows.size());
|
||||
for (std::size_t i = 0; i < s_rows.size(); ++i)
|
||||
for (std::size_t i = from; 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 };
|
||||
res.mnemonic = bt.mnemonicSeed; // read straight off the hdchain record
|
||||
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 };
|
||||
// A cap-truncated btree walk still yields DEFINITIVE positives (a found marker is
|
||||
// authoritative even when the scan didn't finish), so carry what it read — notably
|
||||
// the fMnemonicSeed flag. Otherwise a large wallet probed after the shared budget is
|
||||
// spent loses its seed/legacy classification and the row falls back to the (possibly
|
||||
// stale) runtime badge, mislabelling a legacy wallet as a seed-phrase wallet.
|
||||
if (bt.mnemonicSeed != 0) res.mnemonic = bt.mnemonicSeed;
|
||||
if (bt.hdSeed) res.hdSeed = true;
|
||||
if (bt.encrypted) res.encrypted = true;
|
||||
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()) 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);
|
||||
};
|
||||
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
|
||||
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(util::Platform::getDragonXDataDir(), 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(runProbe, batch, targets).detach();
|
||||
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 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)
|
||||
|
||||
@@ -383,6 +383,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["wallets_external_tt"] = "Outside your data directory \xE2\x80\x94 Open links it in place (no copy).";
|
||||
strings_["wallets_scan_folder"] = "Scan another folder for wallets\xE2\x80\xA6";
|
||||
strings_["wallets_scanned_folders"] = "Scanned folders:";
|
||||
strings_["wallets_scanning"] = "Scanning folders\xE2\x80\xA6";
|
||||
strings_["wallets_remove_folder"] = "Stop scanning this folder";
|
||||
strings_["wallets_empty_hint"] = "Scan a folder to find more wallets";
|
||||
strings_["wallets_folder_invalid"] = "That folder doesn't exist.";
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// wallet_folder_scan.h - enumerate wallet .dat files in the datadir (top level) and in user-added folders
|
||||
// (bounded recursive walk). Pure filesystem logic (no UI, no App), moved verbatim out of
|
||||
// WalletsDialog::scan() so the external walk can run on a worker thread and be unit-tested.
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "wallet_file_probe.h" // isInPlaceLinkName
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
struct ScannedWallet {
|
||||
std::string fileName;
|
||||
std::string dir; // the file's ACTUAL parent (may be a subdir) — Open reads dir + "/" + fileName
|
||||
std::string canonPath; // symlink-resolved, absolute: the STABLE identity of the physical file (two paths
|
||||
// reaching one wallet share it); used for the in-place link name + row de-dup
|
||||
bool inDatadir = false;
|
||||
long long sizeBytes = 0;
|
||||
};
|
||||
|
||||
struct WalletScanLimits {
|
||||
int maxDepth = 8; // don't descend deeper than this below a chosen folder
|
||||
std::size_t maxHits = 200; // cap on total rows listed (datadir + external)
|
||||
int maxVisited = 40000; // cap on entries walked per external folder
|
||||
};
|
||||
|
||||
// Node/daemon .dat files that are NOT wallets (a user can point the picker at a folder that IS or CONTAINS a
|
||||
// datadir, so these are filtered everywhere or the list fills with blk*/rev* block files, peers.dat, …).
|
||||
inline bool isWalletNodeArtifact(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
|
||||
}
|
||||
// Directory names a recursive external scan never descends into (no user wallets; would burn the budget).
|
||||
inline bool isWalletNodeSubtree(const std::string& n) {
|
||||
return n == "blocks" || n == "chainstate" || n == "database" || n == "indexes" || n == "index";
|
||||
}
|
||||
|
||||
inline std::string canonicalWalletPath(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();
|
||||
}
|
||||
|
||||
// String checks FIRST (no syscalls; most entries in a big tree are neither .dat nor wallets), then stat /
|
||||
// canonical only for plausible candidates. Datadir: wallet-prefixed, in-place links hidden. External: any
|
||||
// .dat except node junk. One row per physical wallet via seenCanon.
|
||||
inline bool considerWalletFile(const std::filesystem::path& p, bool inDatadir,
|
||||
std::unordered_set<std::string>& seenCanon, ScannedWallet& out) {
|
||||
namespace fs = std::filesystem;
|
||||
const std::string name = p.filename().string();
|
||||
if (name.size() <= 4 || name.substr(name.size() - 4) != ".dat") return false;
|
||||
if (inDatadir) { if (name.rfind("wallet", 0) != 0 || isInPlaceLinkName(name)) return false; }
|
||||
else { if (isWalletNodeArtifact(name)) return false; }
|
||||
std::error_code ec;
|
||||
if (!fs::is_regular_file(p, ec)) return false;
|
||||
ScannedWallet r;
|
||||
r.fileName = name;
|
||||
r.dir = p.parent_path().string();
|
||||
r.canonPath = canonicalWalletPath(p);
|
||||
if (!seenCanon.insert(r.canonPath).second) return false; // same physical wallet already listed
|
||||
r.inDatadir = inDatadir;
|
||||
r.sizeBytes = static_cast<long long>(fs::file_size(p, ec));
|
||||
out = std::move(r);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Top-level wallet*.dat files of the datadir (never descends into blocks/ chainstate/ …). Cheap: UI thread.
|
||||
inline std::vector<ScannedWallet> scanDatadirWallets(const std::string& datadir,
|
||||
std::unordered_set<std::string>& seenCanon) {
|
||||
namespace fs = std::filesystem;
|
||||
std::vector<ScannedWallet> out;
|
||||
std::error_code ec;
|
||||
if (datadir.empty() || !fs::is_directory(datadir, ec)) return out;
|
||||
fs::directory_iterator it(datadir, ec), end;
|
||||
for (; !ec && it != end; it.increment(ec)) {
|
||||
ScannedWallet w;
|
||||
if (considerWalletFile(it->path(), true, seenCanon, w)) out.push_back(std::move(w));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Bounded recursive walk of the user-added folders. Safe off the UI thread: touches only its arguments.
|
||||
// `cancel` is polled per entry. Directory symlinks are not followed (std default) so cycles can't loop;
|
||||
// permission-denied subtrees are skipped. `alreadyListed` (the caller's datadir rows) counts toward maxHits.
|
||||
inline std::vector<ScannedWallet> scanExternalWalletFolders(const std::vector<std::string>& folders,
|
||||
const WalletScanLimits& lim,
|
||||
std::unordered_set<std::string>& seenCanon,
|
||||
std::size_t alreadyListed,
|
||||
const std::atomic<bool>* cancel = nullptr) {
|
||||
namespace fs = std::filesystem;
|
||||
std::vector<ScannedWallet> out;
|
||||
auto cancelled = [&]() { return cancel && cancel->load(std::memory_order_relaxed); };
|
||||
for (const auto& dir : folders) {
|
||||
if (cancelled()) break;
|
||||
std::error_code ec;
|
||||
if (dir.empty() || !fs::is_directory(dir, ec)) continue;
|
||||
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 (cancelled()) return out;
|
||||
if (++visited > lim.maxVisited) break;
|
||||
std::error_code dec;
|
||||
if (it->is_directory(dec)) {
|
||||
if (it.depth() >= lim.maxDepth || isWalletNodeSubtree(it->path().filename().string()))
|
||||
it.disable_recursion_pending();
|
||||
continue; // directories aren't wallet files
|
||||
}
|
||||
ScannedWallet w;
|
||||
if (considerWalletFile(it->path(), false, seenCanon, w)) out.push_back(std::move(w));
|
||||
if (alreadyListed + out.size() >= lim.maxHits) return out;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// External-result cache policy: reuse only when not forced, the folder list is unchanged and the result is
|
||||
// younger than the TTL. Refresh and a stale-row Open force; add/remove folder change the key.
|
||||
inline std::string externalScanKey(const std::vector<std::string>& folders) {
|
||||
std::string k;
|
||||
for (const auto& f : folders) { k += f; k += '\n'; }
|
||||
return k;
|
||||
}
|
||||
inline bool externalScanCacheUsable(bool valid, const std::string& cachedKey, const std::string& key,
|
||||
double ageSeconds, double ttlSeconds, bool force) {
|
||||
return valid && !force && cachedKey == key && ageSeconds >= 0.0 && ageSeconds < ttlSeconds;
|
||||
}
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
@@ -75,6 +75,7 @@
|
||||
#include "util/background_sampler.h"
|
||||
#include "util/lite_server_probe.h"
|
||||
#include "util/wallet_file_probe.h"
|
||||
#include "util/wallet_folder_scan.h"
|
||||
#include "wallet/lite_connection_service.h"
|
||||
#include "wallet/lite_diagnostics.h"
|
||||
#include "wallet/lite_owned_string.h"
|
||||
@@ -3340,6 +3341,123 @@ void testVerifySaplingParams()
|
||||
fsn::remove_all(dir2, rmec);
|
||||
}
|
||||
|
||||
// Wallets dialog: the pure folder walk that moved out of WalletsDialog::scan() (now run on a worker).
|
||||
void testWalletFolderScan()
|
||||
{
|
||||
using namespace dragonx::util;
|
||||
namespace fsn = std::filesystem;
|
||||
const fsn::path root = makeTempDir() / "wallet_folder_scan";
|
||||
const fsn::path dd = root / "datadir", ext = root / "ext", ext2 = root / "ext2";
|
||||
auto touch = [](const fsn::path& p, const std::string& body = "x") {
|
||||
std::error_code ec;
|
||||
fsn::create_directories(p.parent_path(), ec);
|
||||
std::ofstream(p.string(), std::ios::binary) << body;
|
||||
};
|
||||
auto names = [](const std::vector<ScannedWallet>& v) {
|
||||
std::set<std::string> s;
|
||||
for (const auto& w : v) s.insert(w.fileName);
|
||||
return s;
|
||||
};
|
||||
|
||||
// Datadir: top level only, wallet-prefixed, in-place links and node files hidden.
|
||||
for (const char* n : { "wallet.dat", "wallet-x.dat", "wallet-ip-1a.dat", "peers.dat", "other.dat" }) touch(dd / n);
|
||||
touch(dd / "blocks" / "blk00000.dat");
|
||||
{
|
||||
std::unordered_set<std::string> seen;
|
||||
const auto rows = scanDatadirWallets(dd.string(), seen);
|
||||
EXPECT_EQ(rows.size(), static_cast<std::size_t>(2));
|
||||
EXPECT_TRUE((names(rows) == std::set<std::string>{ "wallet.dat", "wallet-x.dat" }));
|
||||
for (const auto& w : rows) EXPECT_TRUE(w.inDatadir);
|
||||
EXPECT_EQ(seen.size(), static_cast<std::size_t>(2));
|
||||
std::unordered_set<std::string> seen2;
|
||||
EXPECT_TRUE(scanDatadirWallets((root / "missing").string(), seen2).empty());
|
||||
}
|
||||
|
||||
// External: any .dat except node junk, recursive within the depth cap, never into node subtrees.
|
||||
touch(ext / "a.dat");
|
||||
touch(ext / "peers.dat");
|
||||
touch(ext / "blk1.dat");
|
||||
touch(ext / "blocks" / "b.dat");
|
||||
touch(ext / "sub" / "w.dat");
|
||||
touch(ext / "d1" / "d2" / "d3" / "deep.dat");
|
||||
touch(ext / "notes.txt");
|
||||
{
|
||||
WalletScanLimits lim;
|
||||
lim.maxDepth = 2;
|
||||
std::unordered_set<std::string> seen;
|
||||
const auto rows = scanExternalWalletFolders({ ext.string() }, lim, seen, 0);
|
||||
EXPECT_TRUE((names(rows) == std::set<std::string>{ "a.dat", "w.dat" }));
|
||||
for (const auto& w : rows) {
|
||||
EXPECT_TRUE(!w.inDatadir);
|
||||
if (w.fileName == "w.dat") EXPECT_TRUE(fsn::path(w.dir) == ext / "sub"); // the real parent
|
||||
if (w.fileName == "a.dat") EXPECT_TRUE(fsn::path(w.dir) == ext);
|
||||
}
|
||||
}
|
||||
{
|
||||
std::unordered_set<std::string> seen;
|
||||
const auto rows = scanExternalWalletFolders({ ext.string() }, WalletScanLimits{}, seen, 0);
|
||||
EXPECT_TRUE((names(rows) == std::set<std::string>{ "a.dat", "w.dat", "deep.dat" }));
|
||||
}
|
||||
// The hit cap holds across folders.
|
||||
touch(ext2 / "e.dat");
|
||||
{
|
||||
WalletScanLimits lim;
|
||||
lim.maxHits = 1;
|
||||
std::unordered_set<std::string> seen;
|
||||
EXPECT_EQ(scanExternalWalletFolders({ ext.string(), ext2.string() }, lim, seen, 0).size(),
|
||||
static_cast<std::size_t>(1));
|
||||
}
|
||||
// The visit cap stops the walk early.
|
||||
{
|
||||
WalletScanLimits lim;
|
||||
lim.maxVisited = 1;
|
||||
std::unordered_set<std::string> seen;
|
||||
EXPECT_TRUE(scanExternalWalletFolders({ ext.string() }, lim, seen, 0).size() <= 1);
|
||||
}
|
||||
// Rows already listed (the datadir pass) count toward the hit cap.
|
||||
{
|
||||
std::unordered_set<std::string> seen;
|
||||
EXPECT_TRUE(scanExternalWalletFolders({ ext.string() }, WalletScanLimits{}, seen, 200).size() <= 1);
|
||||
}
|
||||
// A cancelled walk returns nothing.
|
||||
{
|
||||
std::atomic<bool> cancel{true};
|
||||
std::unordered_set<std::string> seen;
|
||||
EXPECT_TRUE(scanExternalWalletFolders({ ext.string() }, WalletScanLimits{}, seen, 0, &cancel).empty());
|
||||
}
|
||||
#ifndef _WIN32
|
||||
// A symlink to a datadir wallet inside an added folder is the same physical wallet: listed once.
|
||||
{
|
||||
const fsn::path lnDir = root / "ext_link";
|
||||
std::error_code ec;
|
||||
fsn::create_directories(lnDir, ec);
|
||||
fsn::create_symlink(dd / "wallet.dat", lnDir / "linked.dat", ec);
|
||||
EXPECT_TRUE(!ec);
|
||||
touch(lnDir / "own.dat");
|
||||
std::unordered_set<std::string> seen;
|
||||
const auto dataRows = scanDatadirWallets(dd.string(), seen);
|
||||
const auto rows = scanExternalWalletFolders({ lnDir.string() }, WalletScanLimits{}, seen, dataRows.size());
|
||||
EXPECT_TRUE((names(rows) == std::set<std::string>{ "own.dat" }));
|
||||
}
|
||||
#endif
|
||||
removeTree(root.parent_path());
|
||||
}
|
||||
|
||||
void testExternalScanCache()
|
||||
{
|
||||
using dragonx::util::externalScanKey;
|
||||
using dragonx::util::externalScanCacheUsable;
|
||||
EXPECT_TRUE(externalScanKey({ "a", "b" }) != externalScanKey({ "ab" }));
|
||||
EXPECT_TRUE(externalScanKey({}).empty());
|
||||
const std::string k = externalScanKey({ "/home/u/wallets" });
|
||||
EXPECT_TRUE(externalScanCacheUsable(true, k, k, 10.0, 60.0, false));
|
||||
EXPECT_TRUE(!externalScanCacheUsable(true, k, k, 10.0, 60.0, /*force=*/true));
|
||||
EXPECT_TRUE(!externalScanCacheUsable(true, k, externalScanKey({ "/other" }), 10.0, 60.0, false));
|
||||
EXPECT_TRUE(!externalScanCacheUsable(true, k, k, 61.0, 60.0, false));
|
||||
EXPECT_TRUE(!externalScanCacheUsable(false, k, k, 10.0, 60.0, false));
|
||||
EXPECT_TRUE(!externalScanCacheUsable(true, k, k, -1.0, 60.0, false));
|
||||
}
|
||||
|
||||
void testPlatformEnsureDirectory()
|
||||
{
|
||||
using dragonx::util::Platform;
|
||||
@@ -13570,6 +13688,8 @@ int main()
|
||||
#endif
|
||||
testPlatformEnsureDirectory();
|
||||
testVerifySaplingParams();
|
||||
testWalletFolderScan();
|
||||
testExternalScanCache();
|
||||
testConnectHasStalled();
|
||||
testIsLocalHost();
|
||||
testAllowsPlaintextRemote();
|
||||
|
||||
Reference in New Issue
Block a user