feat(wallets): open external wallets in place via datadir link (no copy)

Replace the "Import" action — which copied an out-of-datadir wallet into the
datadir and cluttered the list with hard-to-tell-apart duplicates — with an
in-place "Open" that links the real file into the datadir under a stable
per-target name (wallet-ip-<FNV8>.dat) and switches to it. The daemon only
loads a bare filename from its own datadir, so a link is the minimal bridge:
hard link first (no privileges, same volume — covers non-admin Windows),
symlink fallback (cross-volume), then an error. Never a copy (that would fork
the wallet) and never a delete of a real file.

Also:
- show each external wallet's originating sub-directory (…/Backups/2021)
- add a per-row "open folder location" button
- widen the modal (780 → 860) for the extra button
- guard the daemon against a dangling link (external file moved / USB
  unplugged) by falling back to wallet.dat rather than creating an empty one

Per-target link names (not one shared name) make switching between two
external wallets a real -wallet switch, so the rescan/cache index tracks each
correctly. Drops the now-dead wallets_import* i18n keys and back-fills the new
open/folder strings across all 8 languages + rebuilds the CJK subset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 21:21:30 -05:00
parent b765d62e00
commit b7da6335d1
13 changed files with 166 additions and 94 deletions

View File

@@ -1,7 +1,10 @@
#include "daemon_controller.h"
#include "../config/settings.h"
#include "../util/platform.h"
#include <algorithm>
#include <filesystem>
#include <system_error>
namespace dragonx {
namespace daemon {
@@ -23,7 +26,18 @@ void DaemonController::syncSettings(const config::Settings* settings)
if (!settings) return;
daemon_->setDebugCategories(settings->getDebugCategories());
daemon_->setMaxConnections(settings->getMaxConnections());
daemon_->setWalletFile(settings->getActiveWalletFile());
std::string walletFile = settings->getActiveWalletFile();
// The Wallets dialog opens an out-of-datadir wallet by linking it into the datadir under a
// "wallet-ip-<hash>.dat" name. If that link went dangling (the external file was moved / a USB was
// unplugged between sessions), don't let the daemon create a fresh EMPTY wallet at that name — fall
// back to the default this launch. fs::exists follows the link, so it's false for a dangling one.
if (walletFile.rfind("wallet-ip-", 0) == 0) {
std::error_code ec;
if (!std::filesystem::exists(util::Platform::getDragonXDataDir() + "/" + walletFile, ec))
walletFile = "wallet.dat";
}
daemon_->setWalletFile(walletFile);
}
bool DaemonController::start(const config::Settings* settings)

View File

@@ -103,7 +103,7 @@ public:
ov.title = TR("wallets_title");
ov.p_open = &s_open;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 780.0f;
ov.cardWidth = 860.0f; // roomier — makes space for the per-row "open folder" button
ov.cardHeight = cardH;
ov.idSuffix = "wallets";
if (BeginOverlayDialog(ov)) {
@@ -111,6 +111,15 @@ public:
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.dir + "/" + row.fileName) == 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). ----------------------------------
@@ -170,9 +179,8 @@ public:
default: sortVal[k] = pr.createdEpoch; break; // 0 = date created
}
}
auto isActive = [&](std::size_t i) { return s_rows[i].inDatadir && s_rows[i].fileName == active; };
std::stable_sort(s_order.begin(), s_order.end(), [&](std::size_t a, std::size_t b) {
const bool aa = isActive(a), ba = isActive(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];
});
@@ -230,7 +238,7 @@ public:
// subdir scan) must NOT borrow a datadir wallet's cached balance/addresses. Only look up
// metadata for datadir rows; external rows show size + "never opened" (accurate).
const data::WalletIndexEntry* meta = r.inDatadir ? app->walletIndex().find(r.fileName) : nullptr;
const bool isCurrent = r.inDatadir && r.fileName == active;
const bool isCurrent = rowActive[i] != 0;
ImVec2 rMin = ImGui::GetCursorScreenPos();
ImVec2 rMax(rMin.x + rowW, rMin.y + walRowH);
@@ -254,10 +262,12 @@ public:
isCurrent ? Success() : OnSurfaceMedium(), ICON_MD_ACCOUNT_BALANCE_WALLET);
x += iconSz + Layout::spacingMd();
// Action-button slot on the right — reserve room so text truncates before it.
// 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;
const float textR = btnX - Layout::spacingMd();
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
@@ -332,25 +342,32 @@ public:
dl->AddText(metaFont, metaFont->LegacySize, ImVec2(x, topY + nameFont->LegacySize + Layout::spacingXs()),
OnSurfaceMedium(), ms.c_str());
// Action: Active chip (current) / Open / Import
// 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 if (r.inDatadir) {
ImGui::SetCursorScreenPos(ImVec2(btnX, midY - btnH * 0.5f));
if (StyledButton(TR("wallets_open"), ImVec2(btnW, btnH))) {
app->switchToWallet(r.fileName);
s_open = false;
}
} else {
ImGui::SetCursorScreenPos(ImVec2(btnX, midY - btnH * 0.5f));
if (StyledButton(TR("wallets_import"), ImVec2(btnW, btnH)))
importAndOpen(app, r);
if (ImGui::IsItemHovered()) Tooltip("%s", TR("wallets_import_tt"));
if (StyledButton(TR("wallets_open"), ImVec2(btnW, btnH))) {
if (r.inDatadir) { app->switchToWallet(r.fileName); s_open = false; }
else { openInPlace(app, r); }
}
if (!r.inDatadir && ImGui::IsItemHovered()) Tooltip("%s", TR("wallets_open_inplace_tt"));
}
ImGui::PopID();
@@ -375,7 +392,7 @@ public:
if (StyledButton(TR("wallets_create"), ImVec2(createBtnW, 0))) {
std::string name = normalizeWalletName(s_newName);
std::error_code ec;
if (name.empty()) {
if (name.empty() || isLinkName(name)) { // reserve the in-place-link prefix
Notifications::instance().warning(TR("wallets_name_invalid"));
} else if (std::filesystem::exists(util::Platform::getDragonXDataDir() + "/" + name, ec)) {
Notifications::instance().warning(TR("wallets_exists"));
@@ -473,30 +490,53 @@ private:
return clean + ".dat";
}
// Copy an out-of-datadir wallet file into the datadir (the daemon only loads plain filenames
// from there) under a wallet-*.dat name, then switch to it. Never overwrites an existing file.
static void importAndOpen(App* app, const WalletRow& r) {
// A reserved bare-filename PREFIX for the datadir links that let out-of-datadir wallets open in place.
// Each external wallet gets its own STABLE link name derived from its path — so switching between two
// of them is a real -wallet=<different name> switch (not a no-op on one shared name), and the wallet
// index tracks each separately (correct per-wallet rescan + cached data). Hidden from the list.
static constexpr const char* kLinkPrefix = "wallet-ip-";
static bool isLinkName(const std::string& n) { return n.rfind(kLinkPrefix, 0) == 0; }
// Stable per-target bare link name, e.g. "wallet-ip-1a2b3c4d.dat" (FNV-1a of the absolute path — a
// deterministic, cross-platform, cross-run hash, unlike std::hash).
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;
}
// 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. Prefer a hard link (no
// privileges, same volume — covers most users incl. non-admin Windows), fall back to a symlink
// (cross-volume). We NEVER copy (that would fork the wallet), and NEVER delete a real file.
static void openInPlace(App* app, const WalletRow& r) {
namespace fs = std::filesystem;
const std::string base = normalizeWalletName(r.fileName);
if (base.empty()) { Notifications::instance().warning(TR("wallets_name_invalid")); return; }
std::error_code ec;
const std::string datadir = util::Platform::getDragonXDataDir();
const std::string datadir = util::Platform::getDragonXDataDir();
fs::create_directories(datadir, ec);
// The recursive subdir scan can surface several distinct wallets sharing one basename (e.g. a
// "wallet.dat" in two backup folders). Auto-suffix the datadir destination to the next free name
// instead of hard-failing, so the second one is importable too. Never overwrites an existing file.
const std::string stem = base.substr(0, base.size() - 4); // strip ".dat"
std::string dest = base;
std::string destPath = datadir + "/" + dest;
for (int n = 2; n < 1000 && fs::exists(destPath, ec); ++n) {
dest = stem + "-" + std::to_string(n) + ".dat";
destPath = datadir + "/" + dest;
const std::string src = r.dir + "/" + r.fileName;
const std::string linkName = linkNameFor(src);
const std::string linkPath = datadir + "/" + linkName;
// Clear only a prior LINK for this same wallet — never a standalone regular file (belt-and-braces;
// the reserved prefix already keeps users from creating a real wallet with this name).
if (fs::exists(linkPath, ec)) {
if (fs::is_symlink(linkPath, ec) || fs::hard_link_count(linkPath, ec) > 1) {
fs::remove(linkPath, ec);
} else {
app->switchToWallet(linkName); s_open = false; return; // already a real file at our name — reuse, don't touch
}
}
if (fs::exists(destPath, ec)) { Notifications::instance().warning(TR("wallets_exists")); return; }
fs::copy_file(r.dir + "/" + r.fileName, destPath, ec);
if (ec) { Notifications::instance().error(TR("wallets_import_failed")); return; }
Notifications::instance().success(TR("wallets_imported"));
app->switchToWallet(dest);
const std::string target = [&]{ auto c = fs::weakly_canonical(src, ec); return c.empty() ? src : c.string(); }(); // resolve a symlinked source
std::error_code lec;
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 privilege on Windows)
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;
}
@@ -531,7 +571,7 @@ private:
// neither .dat nor wallets, so reject them before paying for a stat().
const std::string name = p.filename().string();
if (name.size() <= 4 || name.substr(name.size() - 4) != ".dat") return;
if (inDatadir) { if (name.rfind("wallet", 0) != 0) return; } // datadir: wallet-prefixed only
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;

View File

@@ -281,20 +281,18 @@ void I18n::loadBuiltinEnglish()
strings_["wallets_badge_legacy_short"] = "Legacy";
strings_["wallets_badge_unknown_short"] = "Unknown";
strings_["wallets_open"] = "Open";
strings_["wallets_open_folder"] = "Open folder location";
strings_["wallets_open_inplace_tt"] = "Open this wallet where it is — linked into the data directory (no copy)";
strings_["wallets_open_failed"] = "Couldn't open this wallet in place. It's likely on a different drive than your data directory — move it onto the same drive (on Windows, enabling Developer Mode also lets it link across drives).";
strings_["wallets_never"] = "Never opened";
strings_["wallets_import_hint"] = "Import to open";
strings_["wallets_import_tt"] = "This wallet is outside the data directory. Opening it will copy it in first (coming soon).";
strings_["wallets_external_tt"] = "Outside your data directory \xE2\x80\x94 Import to copy it in.";
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_folder_invalid"] = "That folder doesn't exist.";
strings_["wallets_import"] = "Import";
strings_["wallets_new_label"] = "Create a new wallet:";
strings_["wallets_new_hint"] = "Name (e.g. savings)";
strings_["wallets_create"] = "Create wallet";
strings_["wallets_name_invalid"] = "Please enter a valid wallet name.";
strings_["wallets_exists"] = "A wallet with that name already exists.";
strings_["wallets_imported"] = "Wallet imported — switching…";
strings_["wallets_import_failed"] = "Could not import that wallet file.";
strings_["wallets_creating"] = "Creating wallet — the node will restart…";
strings_["wallets_reveal"] = "Reveal folder";
// In-app folder picker (Scan another folder)