feat(wallet): wallet-files list + switching (P2)
Second phase of multi-wallet: list wallet files and switch the active one. - Daemon plumbing: EmbeddedDaemon::setWalletFile -> start() passes -wallet=<name> (non-default only; skipped during the isolated seed-migration start), and DaemonController::syncSettings pushes active_wallet_file on each start. - App::switchToWallet: persist the new active wallet, then stop + restart the node on -wallet=<name> (rescan only if it was never synced in this datadir). Reuses the seed-adopt restart coordination; WalletState clears on disconnect and the P1 identity-scoped caches re-key, so no previous-wallet data leaks. Guarded: full-node, embedded daemon, not mid-restart, no pending send. - Wallets dialog (Settings -> Backup & Data -> "Wallets…"): a table of wallet files (datadir wallet*.dat + user-added folders' *.dat) with size (disk) and cached balance / address count / last-opened (wallet index), a current/Active badge, Open (switch), and Add folder. Out-of-datadir wallets show "import to open" (P3). Added as a sweep surface. - Mining payout safety: non-destructive warning if the pool-mode worker looks like a DragonX address not in the current wallet (stale after a switch). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
207
src/ui/windows/wallets_dialog.h
Normal file
207
src/ui/windows/wallets_dialog.h
Normal file
@@ -0,0 +1,207 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// Wallet-files list + switcher (multi-wallet P2). Lists wallet files in the datadir (and any
|
||||
// user-added folders) with cached metadata (size from disk; balance / address count / last-opened
|
||||
// from the wallet index, since those can't be read off a wallet.dat without loading it). "Open"
|
||||
// switches the active wallet via App::switchToWallet (daemon restart on -wallet=<name>).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "imgui.h"
|
||||
|
||||
#include "../../app.h"
|
||||
#include "../../config/settings.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/platform.h"
|
||||
#include "../../util/text_format.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
#include "../notifications.h"
|
||||
#include "../layout.h"
|
||||
#include "../material/colors.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
#include "../material/type.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
class WalletsDialog {
|
||||
public:
|
||||
static void show(App* app) {
|
||||
s_open = true;
|
||||
s_app = app;
|
||||
s_needScan = true;
|
||||
s_newFolder[0] = '\0';
|
||||
}
|
||||
|
||||
static void hide() { s_open = false; }
|
||||
|
||||
static void render() {
|
||||
if (!s_open || !s_app) return;
|
||||
using namespace material;
|
||||
App* app = s_app;
|
||||
const float dp = Layout::dpiScale();
|
||||
|
||||
if (s_needScan) { scan(); s_needScan = false; }
|
||||
|
||||
if (BeginOverlayDialog(TR("wallets_title"), &s_open, 780.0f, 0.94f)) {
|
||||
Type().text(TypeStyle::H6, TR("wallets_title"));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_intro"));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
|
||||
const std::string active = app->settings() ? app->settings()->getActiveWalletFile() : "wallet.dat";
|
||||
|
||||
// Leave room below the table for the add-folder row + footer buttons.
|
||||
float tableH = ImGui::GetContentRegionAvail().y - 130.0f * dp;
|
||||
if (tableH < 120.0f * dp) tableH = 120.0f * dp;
|
||||
|
||||
const ImGuiTableFlags tflags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg |
|
||||
ImGuiTableFlags_BordersInnerH;
|
||||
if (ImGui::BeginTable("##wallets", 6, tflags, ImVec2(0, tableH))) {
|
||||
ImGui::TableSetupColumn(TR("wallets_col_name"), ImGuiTableColumnFlags_WidthStretch);
|
||||
ImGui::TableSetupColumn(TR("wallets_col_size"), ImGuiTableColumnFlags_WidthFixed, 90.0f * dp);
|
||||
ImGui::TableSetupColumn(TR("wallets_col_addresses"), ImGuiTableColumnFlags_WidthFixed, 80.0f * dp);
|
||||
ImGui::TableSetupColumn(TR("wallets_col_balance"), ImGuiTableColumnFlags_WidthFixed, 130.0f * dp);
|
||||
ImGui::TableSetupColumn(TR("wallets_col_opened"), ImGuiTableColumnFlags_WidthFixed, 120.0f * dp);
|
||||
ImGui::TableSetupColumn("##action", ImGuiTableColumnFlags_WidthFixed, 110.0f * dp);
|
||||
ImGui::TableSetupScrollFreeze(0, 1);
|
||||
ImGui::TableHeadersRow();
|
||||
|
||||
for (std::size_t i = 0; i < s_rows.size(); ++i) {
|
||||
const WalletRow& r = s_rows[i];
|
||||
const data::WalletIndexEntry* meta = app->walletIndex().find(r.fileName);
|
||||
const bool isCurrent = r.inDatadir && r.fileName == active;
|
||||
|
||||
ImGui::TableNextRow();
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
|
||||
// Name — + "current" badge, or a folder marker for out-of-datadir files.
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::TextUnformatted(r.fileName.c_str());
|
||||
if (isCurrent) {
|
||||
ImGui::SameLine(0, 8.0f * dp);
|
||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(Success()), "%s", TR("wallets_current"));
|
||||
} else if (!r.inDatadir) {
|
||||
ImGui::SameLine(0, 8.0f * dp);
|
||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "%s", ICON_MD_FOLDER);
|
||||
if (ImGui::IsItemHovered()) Tooltip("%s", r.dir.c_str());
|
||||
}
|
||||
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::TextUnformatted(util::Platform::formatFileSize(static_cast<uint64_t>(r.sizeBytes)).c_str());
|
||||
|
||||
ImGui::TableNextColumn();
|
||||
if (meta && meta->cachedAddressCount >= 0) ImGui::Text("%lld", meta->cachedAddressCount);
|
||||
else ImGui::TextDisabled("—");
|
||||
|
||||
ImGui::TableNextColumn();
|
||||
if (meta && meta->cachedBalance >= 0.0) ImGui::Text("%.4f", meta->cachedBalance);
|
||||
else ImGui::TextDisabled("—");
|
||||
|
||||
ImGui::TableNextColumn();
|
||||
if (meta && meta->lastOpenedEpoch > 0)
|
||||
ImGui::TextUnformatted(util::formatTimeAgo(meta->lastOpenedEpoch).c_str());
|
||||
else ImGui::TextDisabled("%s", TR("wallets_never"));
|
||||
|
||||
// Action
|
||||
ImGui::TableNextColumn();
|
||||
if (isCurrent) {
|
||||
ImGui::TextDisabled("%s", TR("wallets_active"));
|
||||
} else if (r.inDatadir) {
|
||||
if (StyledButton(TR("wallets_open"), ImVec2(90.0f * dp, 0))) {
|
||||
app->switchToWallet(r.fileName);
|
||||
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"));
|
||||
if (ImGui::IsItemHovered()) Tooltip("%s", TR("wallets_import_tt"));
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
ImGui::EndTable();
|
||||
}
|
||||
|
||||
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);
|
||||
ImGui::InputTextWithHint("##walletFolder", TR("wallets_folder_hint"), s_newFolder, sizeof(s_newFolder));
|
||||
ImGui::SameLine();
|
||||
if (StyledButton(TR("wallets_add"), ImVec2(120.0f * dp, 0))) {
|
||||
std::error_code ec;
|
||||
std::string dir = s_newFolder;
|
||||
if (!dir.empty() && std::filesystem::is_directory(dir, ec)) {
|
||||
if (app->walletIndex().addExtraFolder(dir)) app->walletIndex().save();
|
||||
s_newFolder[0] = '\0';
|
||||
s_needScan = true;
|
||||
} else {
|
||||
Notifications::instance().warning(TR("wallets_folder_invalid"));
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
ImGui::Separator();
|
||||
if (StyledButton(TR("refresh"), ImVec2(110.0f * dp, 0))) s_needScan = true;
|
||||
ImGui::SameLine();
|
||||
if (StyledButton(TR("close"), ImVec2(110.0f * dp, 0))) s_open = false;
|
||||
|
||||
EndOverlayDialog();
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
struct WalletRow {
|
||||
std::string fileName;
|
||||
std::string dir;
|
||||
bool inDatadir = false;
|
||||
long long sizeBytes = 0;
|
||||
};
|
||||
|
||||
// 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() {
|
||||
s_rows.clear();
|
||||
namespace fs = std::filesystem;
|
||||
auto addFrom = [](const std::string& dir, bool inDatadir) {
|
||||
std::error_code ec;
|
||||
if (dir.empty() || !fs::is_directory(dir, ec)) return;
|
||||
fs::directory_iterator it(dir, ec), end;
|
||||
for (; !ec && it != end; it.increment(ec)) {
|
||||
std::error_code fec;
|
||||
if (!it->is_regular_file(fec)) continue;
|
||||
std::string name = it->path().filename().string();
|
||||
if (name.size() <= 4 || name.substr(name.size() - 4) != ".dat") continue;
|
||||
if (inDatadir && name.rfind("wallet", 0) != 0) continue;
|
||||
WalletRow r;
|
||||
r.fileName = name;
|
||||
r.dir = dir;
|
||||
r.inDatadir = inDatadir;
|
||||
r.sizeBytes = static_cast<long long>(fs::file_size(it->path(), fec));
|
||||
s_rows.push_back(std::move(r));
|
||||
}
|
||||
};
|
||||
addFrom(util::Platform::getDragonXDataDir(), /*inDatadir=*/true);
|
||||
if (s_app)
|
||||
for (const auto& f : s_app->walletIndex().extraFolders())
|
||||
addFrom(f, /*inDatadir=*/false);
|
||||
}
|
||||
|
||||
static inline bool s_open = false;
|
||||
static inline App* s_app = nullptr;
|
||||
static inline bool s_needScan = false;
|
||||
static inline char s_newFolder[512] = "";
|
||||
static inline std::vector<WalletRow> s_rows;
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
Reference in New Issue
Block a user