diff --git a/res/lang/de.json b/res/lang/de.json index 5ed6bb9..d52b8e3 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -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:", diff --git a/res/lang/es.json b/res/lang/es.json index 9166506..fc35386 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -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:", diff --git a/res/lang/fr.json b/res/lang/fr.json index 61300c0..d669d37 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -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 :", diff --git a/res/lang/ja.json b/res/lang/ja.json index b715885..824f581 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -2304,6 +2304,7 @@ "wallets_reveal": "フォルダを開く", "wallets_scan_folder": "別のフォルダをスキャンしてウォレットを検索…", "wallets_scanned_folders": "スキャン対象フォルダー:", + "wallets_scanning": "フォルダをスキャンしています…", "wallets_sort_addresses": "アドレス", "wallets_sort_asc": "昇順(古い/少ない/小さい順)", "wallets_sort_by": "並べ替え:", diff --git a/res/lang/ko.json b/res/lang/ko.json index a39aff0..5e484ba 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -2304,6 +2304,7 @@ "wallets_reveal": "폴더 열기", "wallets_scan_folder": "다른 폴더에서 지갑 검색…", "wallets_scanned_folders": "스캔한 폴더:", + "wallets_scanning": "폴더 검색 중…", "wallets_sort_addresses": "주소", "wallets_sort_asc": "오름차순 (오래된/적은/작은 순)", "wallets_sort_by": "정렬:", diff --git a/res/lang/pt.json b/res/lang/pt.json index 32190d5..fe602bf 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -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:", diff --git a/res/lang/ru.json b/res/lang/ru.json index 474ac91..b638885 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -2304,6 +2304,7 @@ "wallets_reveal": "Открыть папку", "wallets_scan_folder": "Сканировать другую папку на наличие кошельков…", "wallets_scanned_folders": "Просканированные папки:", + "wallets_scanning": "Поиск в папках…", "wallets_sort_addresses": "Адреса", "wallets_sort_asc": "По возрастанию (сначала старые / меньше / меньший)", "wallets_sort_by": "Сортировка:", diff --git a/res/lang/zh.json b/res/lang/zh.json index 8e05a44..f5fb7be 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -2304,6 +2304,7 @@ "wallets_reveal": "打开文件夹", "wallets_scan_folder": "扫描其他文件夹以查找钱包…", "wallets_scanned_folders": "已扫描的文件夹:", + "wallets_scanning": "正在扫描文件夹…", "wallets_sort_addresses": "地址", "wallets_sort_asc": "升序(最早/最少/最小优先)", "wallets_sort_by": "排序:", diff --git a/src/ui/windows/wallets_dialog.h b/src/ui/windows/wallets_dialog.h index 9181a2f..5645f7c 100644 --- a/src/ui/windows/wallets_dialog.h +++ b/src/ui/windows/wallets_dialog.h @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -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 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(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 results; // index-aligned with s_rows at the moment of scan() + std::vector 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 cancel{false}; + std::atomic 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 rows; + std::atomic ready{false}; + std::atomic 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 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(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 batch, + std::vector> 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 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 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(); - 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& batch, std::size_t from) { std::vector> 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 batch, - std::vector> 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 lk(batch->mtx); - if (t.second < batch->results.size()) batch->results[t.second] = res; - } + if (targets.empty()) return; + auto job = [](std::shared_ptr b, std::vector> 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 ext, std::unordered_set& 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 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 sb = std::move(s_scan); + s_scan.reset(); + s_scanning = false; + std::vector rows; + { std::lock_guard 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 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 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(); + s_probe->results.resize(s_rows.size()); + startProbe(s_probe, 0); // datadir badges start filling in at once + + std::vector 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(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(); + sb->key = key; + s_scan = sb; + s_scanning = true; + s_bgWorkSince = std::chrono::steady_clock::now(); + auto work = [](std::shared_ptr b, std::vector fl, + std::unordered_set 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 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 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 s_rows; static inline std::shared_ptr s_probe; // current async probe batch (index-aligned w/ s_rows) diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 8472f0c..b40246f 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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."; diff --git a/src/util/wallet_folder_scan.h b/src/util/wallet_folder_scan.h new file mode 100644 index 0000000..1065a9a --- /dev/null +++ b/src/util/wallet_folder_scan.h @@ -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 +#include +#include +#include +#include +#include +#include + +#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& 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(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 scanDatadirWallets(const std::string& datadir, + std::unordered_set& seenCanon) { + namespace fs = std::filesystem; + std::vector 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 scanExternalWalletFolders(const std::vector& folders, + const WalletScanLimits& lim, + std::unordered_set& seenCanon, + std::size_t alreadyListed, + const std::atomic* cancel = nullptr) { + namespace fs = std::filesystem; + std::vector 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& 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 diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 9c90ada..0bf4bb7 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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& v) { + std::set 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 seen; + const auto rows = scanDatadirWallets(dd.string(), seen); + EXPECT_EQ(rows.size(), static_cast(2)); + EXPECT_TRUE((names(rows) == std::set{ "wallet.dat", "wallet-x.dat" })); + for (const auto& w : rows) EXPECT_TRUE(w.inDatadir); + EXPECT_EQ(seen.size(), static_cast(2)); + std::unordered_set 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 seen; + const auto rows = scanExternalWalletFolders({ ext.string() }, lim, seen, 0); + EXPECT_TRUE((names(rows) == std::set{ "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 seen; + const auto rows = scanExternalWalletFolders({ ext.string() }, WalletScanLimits{}, seen, 0); + EXPECT_TRUE((names(rows) == std::set{ "a.dat", "w.dat", "deep.dat" })); + } + // The hit cap holds across folders. + touch(ext2 / "e.dat"); + { + WalletScanLimits lim; + lim.maxHits = 1; + std::unordered_set seen; + EXPECT_EQ(scanExternalWalletFolders({ ext.string(), ext2.string() }, lim, seen, 0).size(), + static_cast(1)); + } + // The visit cap stops the walk early. + { + WalletScanLimits lim; + lim.maxVisited = 1; + std::unordered_set 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 seen; + EXPECT_TRUE(scanExternalWalletFolders({ ext.string() }, WalletScanLimits{}, seen, 200).size() <= 1); + } + // A cancelled walk returns nothing. + { + std::atomic cancel{true}; + std::unordered_set 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 seen; + const auto dataRows = scanDatadirWallets(dd.string(), seen); + const auto rows = scanExternalWalletFolders({ lnDir.string() }, WalletScanLimits{}, seen, dataRows.size()); + EXPECT_TRUE((names(rows) == std::set{ "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();