feat(wallet): new/import wallets + multi-wallet i18n (P3)

Completes the wallet-files dialog and localizes the feature.

- Create a new wallet: a name is normalized to wallet-<name>.dat and opened
  via switchToWallet; the daemon mints it (seed-backed, -usemnemonic=1) on
  first load. Rejects blank names + name collisions.
- Import: out-of-datadir wallets get an Import action that copies the file
  into the datadir (never overwriting) under a wallet-*.dat name, then opens
  it — the daemon only loads plain filenames from the datadir.
- i18n: the 32 new multi-wallet / per-wallet-contacts / mining-payout strings
  translated into all 8 languages (de/es/fr/ja/ko/pt/ru/zh), CJK subset font
  rebuilt. Verified: the Chinese Wallets dialog renders with no missing glyphs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 17:54:29 -05:00
parent e8a8ce68b2
commit d23ee9b75f
12 changed files with 335 additions and 3 deletions

View File

@@ -9,6 +9,7 @@
#pragma once
#include <cctype>
#include <cstdint>
#include <filesystem>
#include <string>
@@ -120,9 +121,10 @@ public:
s_open = false; // node restarts; the main UI shows the reconnect overlay
}
} else {
// Out-of-datadir wallets can't be loaded directly (the daemon rejects paths);
// opening one means importing (copy into the datadir) — a P3 action.
ImGui::TextDisabled("%s", TR("wallets_import_hint"));
// Out-of-datadir wallets can't be loaded directly (the daemon rejects paths).
// Import = copy into the datadir under a wallet-*.dat name, then switch to it.
if (StyledButton(TR("wallets_import"), ImVec2(90.0f * dp, 0)))
importAndOpen(app, r);
if (ImGui::IsItemHovered()) Tooltip("%s", TR("wallets_import_tt"));
}
ImGui::PopID();
@@ -132,6 +134,28 @@ public:
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Create a fresh named wallet — the daemon mints it (with a seed phrase) on first load.
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_new_label"));
ImGui::SetNextItemWidth(-150.0f * dp);
ImGui::InputTextWithHint("##newWalletName", TR("wallets_new_hint"), s_newName, sizeof(s_newName));
ImGui::SameLine();
if (StyledButton(TR("wallets_create"), ImVec2(120.0f * dp, 0))) {
std::string name = normalizeWalletName(s_newName);
std::error_code ec;
if (name.empty()) {
Notifications::instance().warning(TR("wallets_name_invalid"));
} else if (std::filesystem::exists(util::Platform::getDragonXDataDir() + "/" + name, ec)) {
Notifications::instance().warning(TR("wallets_exists"));
} else {
s_newName[0] = '\0';
Notifications::instance().info(TR("wallets_creating"));
app->switchToWallet(name); // daemon creates it on start (-usemnemonic=1 -> seed-backed)
s_open = false;
}
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Add a folder to also scan for wallet files (no native picker — a validated path input).
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_add_folder"));
ImGui::SetNextItemWidth(-150.0f * dp);
@@ -167,6 +191,42 @@ private:
long long sizeBytes = 0;
};
// Force a "wallet-...dat" filename (plain, no path) so a new/imported wallet shows in the
// datadir scan and can't collide with the daemon's internal .dat files. Returns "" if the
// input has no usable characters.
static std::string normalizeWalletName(const std::string& raw) {
std::string s = raw;
while (!s.empty() && static_cast<unsigned char>(s.front()) <= ' ') s.erase(s.begin());
while (!s.empty() && static_cast<unsigned char>(s.back()) <= ' ') s.pop_back();
auto slash = s.find_last_of("/\\");
if (slash != std::string::npos) s = s.substr(slash + 1);
if (s.size() > 4 && s.substr(s.size() - 4) == ".dat") s = s.substr(0, s.size() - 4);
std::string clean;
for (char c : s)
if (std::isalnum(static_cast<unsigned char>(c)) || c == '-' || c == '_') clean += c;
if (clean.empty()) return "";
if (clean.rfind("wallet", 0) != 0) clean = "wallet-" + clean;
return clean + ".dat";
}
// 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) {
namespace fs = std::filesystem;
std::string dest = normalizeWalletName(r.fileName);
if (dest.empty()) { Notifications::instance().warning(TR("wallets_name_invalid")); return; }
std::error_code ec;
const std::string datadir = util::Platform::getDragonXDataDir();
fs::create_directories(datadir, ec);
const std::string destPath = datadir + "/" + dest;
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);
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). Exception-safe: iterates with error_codes.
static void scan() {
@@ -200,6 +260,7 @@ private:
static inline App* s_app = nullptr;
static inline bool s_needScan = false;
static inline char s_newFolder[512] = "";
static inline char s_newName[128] = "";
static inline std::vector<WalletRow> s_rows;
};