Network tab: glow only the active node, drop the left accent bar. Key-export dialog: fix the lite-wallet "Not connected" failure by exporting the key locally via the SDXL backend when there's no daemon; rework the layout to wrapping click-to-copy fields with a side QR (empty placeholder when hidden), 85% modal width, HRP-preserving key chunking, and a centered, emphasized warning. QR popup matched to the same sizing and click-to-copy address. Shared field rendering extracted to widgets/copy_field.h so both dialogs stay in sync. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
352 lines
16 KiB
C++
352 lines
16 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#include "key_export_dialog.h"
|
|
#include "../../app.h"
|
|
#include "../../wallet/lite_wallet_controller.h"
|
|
#include <nlohmann/json.hpp>
|
|
#include "../../rpc/rpc_client.h"
|
|
#include "../../rpc/rpc_worker.h"
|
|
#include "../../util/i18n.h"
|
|
#include "../schema/ui_schema.h"
|
|
#include "../material/draw_helpers.h"
|
|
#include "../material/type.h"
|
|
#include "../material/colors.h"
|
|
#include "../layout.h"
|
|
#include "../widgets/qr_code.h"
|
|
#include "../widgets/copy_field.h"
|
|
#include "../../embedded/IconsMaterialDesign.h"
|
|
#include "imgui.h"
|
|
|
|
namespace dragonx {
|
|
namespace ui {
|
|
|
|
// Static member initialization
|
|
bool KeyExportDialog::s_open = false;
|
|
bool KeyExportDialog::s_fetching = false;
|
|
bool KeyExportDialog::s_show_key = false;
|
|
bool KeyExportDialog::s_show_qr = false;
|
|
std::uintptr_t KeyExportDialog::s_qr_tex = 0;
|
|
std::string KeyExportDialog::s_qr_cached;
|
|
KeyExportDialog::KeyType KeyExportDialog::s_key_type = KeyExportDialog::KeyType::Private;
|
|
std::string KeyExportDialog::s_address;
|
|
std::string KeyExportDialog::s_key;
|
|
std::string KeyExportDialog::s_error;
|
|
|
|
void KeyExportDialog::releaseQr()
|
|
{
|
|
if (s_qr_tex) { FreeQRTexture(s_qr_tex); s_qr_tex = 0; }
|
|
s_qr_cached.clear();
|
|
s_show_qr = false;
|
|
}
|
|
|
|
void KeyExportDialog::show(const std::string& address, KeyType type)
|
|
{
|
|
s_open = true;
|
|
s_fetching = false;
|
|
s_show_key = false;
|
|
releaseQr();
|
|
s_key_type = type;
|
|
s_address = address;
|
|
s_key.clear();
|
|
s_error.clear();
|
|
}
|
|
|
|
bool KeyExportDialog::isOpen()
|
|
{
|
|
return s_open;
|
|
}
|
|
|
|
void KeyExportDialog::render(App* app)
|
|
{
|
|
if (!s_open) return;
|
|
|
|
const char* title = (s_key_type == KeyType::Private) ?
|
|
TR("export_private_key") : TR("export_viewing_key");
|
|
|
|
auto& S = schema::UI();
|
|
auto warningBox = S.drawElement("dialogs.key-export", "warning-box");
|
|
auto addrInput = S.input("dialogs.key-export", "address-input");
|
|
auto revealBtn = S.button("dialogs.key-export", "reveal-button");
|
|
auto keyDisplay = S.drawElement("dialogs.key-export", "key-display");
|
|
auto copyBtn = S.button("dialogs.key-export", "copy-button");
|
|
auto closeBtn = S.button("dialogs.key-export", "close-button");
|
|
(void)addrInput;
|
|
(void)keyDisplay;
|
|
(void)copyBtn;
|
|
|
|
// Modal scales to 85% of the window width. BeginOverlayDialog multiplies the width by
|
|
// Layout::dpiScale(); divide it out here so the final card is exactly 85% at any font scale.
|
|
const float cardW = (0.85f * ImGui::GetMainViewport()->Size.x) / Layout::dpiScale();
|
|
if (material::BeginOverlayDialog(title, &s_open, cardW, 0.94f)) {
|
|
ImGui::Spacing();
|
|
|
|
// Warning section with colored background
|
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.6f, 0.2f, 0.2f, 0.3f));
|
|
// Auto-size the warning box to its (wrapping, font-scaled) text so it never clips it.
|
|
(void)warningBox;
|
|
ImGui::BeginChild("WarningBox", ImVec2(-1, 0),
|
|
ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_Borders);
|
|
|
|
// Heading — warning icon (drawn with the icon font, not the text font, so it isn't tofu) +
|
|
// larger h6 text, centered, for emphasis.
|
|
{
|
|
ImFont* iconF = material::Type().iconMed();
|
|
ImFont* textF = material::Type().h6();
|
|
const char* icon = ICON_MD_WARNING;
|
|
const char* txt = TR("warning_upper");
|
|
ImGui::PushFont(iconF); const float iw = ImGui::CalcTextSize(icon).x; ImGui::PopFont();
|
|
ImGui::PushFont(textF); const float tw = ImGui::CalcTextSize(txt).x; ImGui::PopFont();
|
|
const float sp = ImGui::GetStyle().ItemSpacing.x;
|
|
const float wa = ImGui::GetContentRegionAvail().x;
|
|
const float total = iw + sp + tw;
|
|
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (wa > total ? (wa - total) * 0.5f : 0.0f));
|
|
const ImVec4 red(1.0f, 0.42f, 0.42f, 1.0f);
|
|
ImGui::PushFont(iconF); ImGui::TextColored(red, "%s", icon); ImGui::PopFont();
|
|
ImGui::SameLine(0, sp);
|
|
ImGui::PushFont(textF); ImGui::TextColored(red, "%s", txt); ImGui::PopFont();
|
|
}
|
|
ImGui::Spacing();
|
|
|
|
// Body — centered wrapped text.
|
|
{
|
|
const char* body = (s_key_type == KeyType::Private)
|
|
? TR("key_export_private_warning") : TR("key_export_viewing_warning");
|
|
ImDrawList* wdl = ImGui::GetWindowDrawList();
|
|
ImFont* wf = ImGui::GetFont();
|
|
const float wfs = ImGui::GetFontSize();
|
|
const float wa = ImGui::GetContentRegionAvail().x;
|
|
const ImVec2 wp = ImGui::GetCursorScreenPos();
|
|
const float bh = widgets::DrawCenteredWrapped(wdl, wf, wfs, wp, wa, material::OnSurface(), body);
|
|
ImGui::Dummy(ImVec2(wa, bh));
|
|
}
|
|
|
|
ImGui::EndChild();
|
|
ImGui::PopStyleColor();
|
|
|
|
ImGui::Spacing();
|
|
ImGui::Separator();
|
|
ImGui::Spacing();
|
|
|
|
// Address — click-to-copy field (chunked, width-clamped, centered).
|
|
ImGui::Text("%s", TR("address_label"));
|
|
widgets::AddressCopyField("##addrcopy", s_address);
|
|
|
|
ImGui::Spacing();
|
|
|
|
// Key display section
|
|
const char* key_label = (s_key_type == KeyType::Private) ? TR("key_export_private_key") : TR("key_export_viewing_key");
|
|
ImGui::Text("%s", key_label);
|
|
|
|
if (s_fetching) {
|
|
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "%s", TR("key_export_fetching"));
|
|
} else if (!s_error.empty()) {
|
|
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), TR("error_format"), s_error.c_str());
|
|
} else if (s_key.empty()) {
|
|
// Show button to fetch key
|
|
if (material::StyledButton(TR("key_export_reveal"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
|
|
s_fetching = true;
|
|
|
|
// Check if z-address or t-address
|
|
bool is_zaddress = (s_address.length() > 50 || s_address[0] == 'z');
|
|
|
|
if (auto* lite = app->liteWallet()) {
|
|
// Lite wallet: there is no daemon RPC — export the key locally via the SDXL
|
|
// backend (a local, network-free op) instead of the z_exportkey/dumpprivkey
|
|
// RPCs, which fail with "Not connected" when no full node is present.
|
|
const bool wantViewing = (s_key_type != KeyType::Private);
|
|
if (wantViewing && !is_zaddress) {
|
|
s_error = TR("key_export_viewing_keys_zonly");
|
|
s_fetching = false;
|
|
} else {
|
|
auto r = lite->exportPrivateKeys(s_address);
|
|
std::string found;
|
|
if (r.ok) {
|
|
try {
|
|
auto j = nlohmann::json::parse(r.privateKeysJson);
|
|
const nlohmann::json* entry = nullptr;
|
|
if (j.is_array()) {
|
|
for (auto& e : j)
|
|
if (e.contains("address") && e["address"] == s_address) { entry = &e; break; }
|
|
if (!entry && !j.empty()) entry = &j.front();
|
|
} else if (j.is_object()) {
|
|
entry = &j;
|
|
}
|
|
const char* field = wantViewing ? "viewing_key" : "private_key";
|
|
if (entry && entry->contains(field) && (*entry)[field].is_string())
|
|
found = (*entry)[field].get<std::string>();
|
|
} catch (...) {}
|
|
wallet::secureWipeLiteSecret(r.privateKeysJson);
|
|
}
|
|
if (!found.empty()) {
|
|
s_key = found;
|
|
s_show_key = wantViewing; // viewing keys are less sensitive
|
|
} else {
|
|
s_error = r.ok ? std::string("Key not available for this address") : r.error;
|
|
}
|
|
wallet::secureWipeLiteSecret(found);
|
|
s_fetching = false;
|
|
}
|
|
} else if (s_key_type == KeyType::Private) {
|
|
// Export private key
|
|
std::string addr = s_address;
|
|
std::string method = is_zaddress ? "z_exportkey" : "dumpprivkey";
|
|
if (app->worker()) {
|
|
app->worker()->post([rpc = app->rpc(), addr, method]() -> rpc::RPCWorker::MainCb {
|
|
std::string key;
|
|
std::string error;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Settings / Export key");
|
|
auto result = rpc->call(method, {addr});
|
|
key = result.get<std::string>();
|
|
} catch (const std::exception& e) {
|
|
error = e.what();
|
|
}
|
|
return [key, error]() {
|
|
if (error.empty()) {
|
|
s_key = key;
|
|
s_show_key = false; // Don't show by default
|
|
} else {
|
|
s_error = error;
|
|
}
|
|
s_fetching = false;
|
|
};
|
|
});
|
|
}
|
|
} else {
|
|
// Export viewing key (only for z-addresses)
|
|
if (is_zaddress) {
|
|
std::string addr = s_address;
|
|
if (app->worker()) {
|
|
app->worker()->post([rpc = app->rpc(), addr]() -> rpc::RPCWorker::MainCb {
|
|
std::string key;
|
|
std::string error;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Settings / Export viewing key");
|
|
auto result = rpc->call("z_exportviewingkey", {addr});
|
|
key = result.get<std::string>();
|
|
} catch (const std::exception& e) {
|
|
error = e.what();
|
|
}
|
|
return [key, error]() {
|
|
if (error.empty()) {
|
|
s_key = key;
|
|
s_show_key = true; // Viewing keys are less sensitive
|
|
} else {
|
|
s_error = error;
|
|
}
|
|
s_fetching = false;
|
|
};
|
|
});
|
|
}
|
|
} else {
|
|
s_error = TR("key_export_viewing_keys_zonly");
|
|
s_fetching = false;
|
|
}
|
|
}
|
|
}
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("%s", TR("key_export_click_retrieve"));
|
|
} else {
|
|
// Key fetched. Layout: [ key text (click-to-copy) + Show/Hide below ] [ QR | square ].
|
|
const float gap = 12.0f;
|
|
const float avail = ImGui::GetContentRegionAvail().x;
|
|
// Larger, responsive QR: ~30% of the content width, clamped to a comfortable range.
|
|
float qrSize = avail * 0.30f;
|
|
if (qrSize < 200.0f) qrSize = 200.0f;
|
|
if (qrSize > 340.0f) qrSize = 340.0f;
|
|
float keyW = avail - qrSize - gap;
|
|
const bool sideBySide = keyW >= 200.0f; // too narrow -> stack the QR under the key
|
|
if (!sideBySide) keyW = avail;
|
|
|
|
// Chunk for readability, but keep a Bech32 HRP (e.g. "secret-extended-key-main") intact
|
|
// rather than slicing it into 4s — only the part from the '1' separator on is chunked.
|
|
auto chunkKey = [](const std::string& s) -> std::string {
|
|
const size_t one = s.find('1');
|
|
if (one != std::string::npos && one > 0) {
|
|
bool isHrp = true;
|
|
for (size_t i = 0; i < one; ++i) {
|
|
const char c = s[i];
|
|
if (!((c >= 'a' && c <= 'z') || c == '-')) { isHrp = false; break; }
|
|
}
|
|
if (isHrp) return s.substr(0, one) + " " + widgets::ChunkString(s.substr(one), 4);
|
|
}
|
|
return widgets::ChunkString(s, 4);
|
|
};
|
|
const std::string shown = s_show_key ? s_key : std::string(s_key.size(), '*');
|
|
const std::string keyText = chunkKey(shown);
|
|
|
|
// Left column: key text (click-to-copy), with the Show/Hide button just below it.
|
|
ImGui::BeginGroup();
|
|
if (widgets::CopyField("##keycopy", keyText, keyW, 0.0f, /*center=*/true)) {
|
|
app->copySecretToClipboard(s_key); // auto-clearing clipboard
|
|
}
|
|
ImGui::Spacing();
|
|
if (material::StyledButton(s_show_key ? TR("hide") : TR("show"), ImVec2(0, 0),
|
|
material::Type().subtitle1())) {
|
|
s_show_key = !s_show_key;
|
|
}
|
|
ImGui::EndGroup();
|
|
|
|
if (sideBySide) ImGui::SameLine(0, gap);
|
|
else ImGui::Spacing();
|
|
|
|
// QR to the right of the key when revealed; an empty placeholder square while hidden.
|
|
const ImVec2 sq0 = ImGui::GetCursorScreenPos();
|
|
if (s_show_key && !s_key.empty()) {
|
|
if (s_qr_cached != s_key) { // (re)generate the QR texture when the key changes
|
|
if (s_qr_tex) { FreeQRTexture(s_qr_tex); s_qr_tex = 0; }
|
|
int qw = 0, qh = 0;
|
|
s_qr_tex = GenerateQRTexture(s_key.c_str(), &qw, &qh);
|
|
s_qr_cached = s_key;
|
|
}
|
|
if (s_qr_tex) {
|
|
// White quiet-zone backing so the code stays scannable on dark themes.
|
|
ImGui::GetWindowDrawList()->AddRectFilled(
|
|
sq0, ImVec2(sq0.x + qrSize, sq0.y + qrSize), IM_COL32_WHITE, 4.0f);
|
|
RenderQRCode(s_qr_tex, qrSize);
|
|
} else {
|
|
ImGui::Dummy(ImVec2(qrSize, qrSize));
|
|
}
|
|
} else {
|
|
// Empty placeholder square — same footprint the QR will occupy once revealed.
|
|
ImGui::Dummy(ImVec2(qrSize, qrSize));
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
const ImVec2 sq1(sq0.x + qrSize, sq0.y + qrSize);
|
|
dl->AddRectFilled(sq0, sq1, material::WithAlpha(material::OnSurface(), 8), 6.0f);
|
|
dl->AddRect(sq0, sq1, material::WithAlpha(material::OnSurface(), 45), 6.0f, 0, 1.0f);
|
|
}
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
ImGui::Separator();
|
|
ImGui::Spacing();
|
|
|
|
// Close button
|
|
float button_width = closeBtn.width;
|
|
float avail_width = ImGui::GetContentRegionAvail().x;
|
|
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (avail_width - button_width) / 2.0f);
|
|
|
|
if (material::StyledButton(TR("close"), ImVec2(button_width, 0), S.resolveFont(closeBtn.font))) {
|
|
s_open = false;
|
|
// Clear sensitive data
|
|
s_key.clear();
|
|
s_show_key = false;
|
|
releaseQr();
|
|
}
|
|
|
|
material::EndOverlayDialog();
|
|
}
|
|
|
|
// Dialog dismissed any other way (scrim click / Esc): drop the key + its QR texture.
|
|
if (!s_open) {
|
|
if (!s_key.empty()) s_key.clear();
|
|
s_show_key = false;
|
|
releaseQr();
|
|
}
|
|
}
|
|
|
|
} // namespace ui
|
|
} // namespace dragonx
|