Add a "Sweep to my wallet (don't keep the key)" option to the Import Private Key dialog (spending keys only). Rather than keeping the key, it imports the key, then sends ALL its funds to a destination you control — a freshly generated shielded address by default, or an existing wallet address you pick. Flow (App::sweepPrivateKey): import the key (rescan; the stock node has no address index, so its UTXOs/notes can't be enumerated otherwise) → confirm it holds spendable funds → resolve the destination → z_sendmany balance-minus-fee from the key's address to the destination, via the existing tested submitZSendMany wrapper + async-operation tracker. The imported key is left in the wallet with an empty balance (there is no remove-key RPC) — stated plainly in the UI caveat. Design notes forced by this daemon (verified against external/dragonx): - z_importkey returns null here, so the shielded source address is found by diffing z_listaddresses before/after import (aborts if the pre-snapshot fails, so a wrong address can never be picked). Transparent uses importprivkey's return. - z_sendmany, NOT z_mergetoaddress: moves a single-UTXO transparent source (the common paper-wallet case) and fails loudly instead of silently leaving a remainder. Amount computed in integer satoshis for an exact fixed-decimal string. - Clear errors for no-funds / unconfirmed / dust-below-fee / already-imported. - The mode + destination are locked during a running sweep; a sweep that finishes while the dialog is closed still shows its Done/Error+txid on reopen; sweep UI can't bleed into the watch-only viewing dialog. i18n: 8 new sweep_* keys (en + 8 langs); dynamic status strings are English, matching the beginSweepToSeedWallet precedent. Reviewed across three adversarial multi-agent rounds (fund-safety focus): round 1 caught the feature was broken for shielded keys (z_importkey null) and rejected single-UTXO transparent sources; round 2 caught reopen/double-submit/precision issues; round 3 verified the fixes. NOT yet exercised against a live daemon — needs a small-amount mainnet test before it's trusted, like migrate-to-seed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5338 lines
247 KiB
C++
5338 lines
247 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
//
|
|
// app.cpp — Main application: init, shutdown, ImGui render loop, NavPage
|
|
// dispatch, dialog rendering, and frame-level state management.
|
|
|
|
#include "app.h"
|
|
#include "config/version.h"
|
|
#include "rpc/rpc_client.h"
|
|
#include "rpc/rpc_worker.h"
|
|
#include "rpc/connection.h"
|
|
#include "config/settings.h"
|
|
#include "wallet/lite_wallet_controller.h"
|
|
#include "wallet/lite_wallet_server_selection_adapter.h"
|
|
#include "wallet/lite_rollout_policy.h"
|
|
#include "wallet/lite_diagnostics.h"
|
|
|
|
#include <cstdlib> // std::getenv for the lite kill-switch env var
|
|
#include <sodium.h> // sodium_memzero for the lite unlock-passphrase buffer
|
|
#include "daemon/daemon_controller.h"
|
|
#include "daemon/embedded_daemon.h"
|
|
#include "daemon/lifecycle_adapters.h"
|
|
#include "daemon/xmrig_manager.h"
|
|
#include "util/pool_registry.h"
|
|
#include "ui/windows/main_window.h"
|
|
#include "ui/windows/balance_tab.h"
|
|
#include "ui/windows/send_tab.h"
|
|
#include "ui/windows/receive_tab.h"
|
|
#include "ui/windows/transactions_tab.h"
|
|
#include "ui/windows/mining_tab.h"
|
|
#include "ui/windows/peers_tab.h"
|
|
#include "ui/windows/network_tab.h"
|
|
#include "ui/windows/console_command_executor.h"
|
|
#include "ui/windows/explorer_tab.h"
|
|
#include "ui/windows/seed_display.h"
|
|
#include "ui/windows/market_tab.h"
|
|
#include "ui/windows/settings_window.h"
|
|
#include "ui/windows/about_dialog.h"
|
|
#include "embedded/IconsMaterialDesign.h"
|
|
#include "ui/windows/key_export_dialog.h"
|
|
#include "ui/windows/transaction_details_dialog.h"
|
|
#include "ui/windows/qr_popup_dialog.h"
|
|
#include "ui/windows/validate_address_dialog.h"
|
|
#include "ui/windows/contacts_tab.h"
|
|
#include "ui/windows/chat_tab.h"
|
|
#include "ui/windows/shield_dialog.h"
|
|
#include "ui/windows/request_payment_dialog.h"
|
|
#include "ui/windows/block_info_dialog.h"
|
|
#include "ui/windows/export_all_keys_dialog.h"
|
|
#include "ui/windows/export_transactions_dialog.h"
|
|
#include "ui/windows/address_label_dialog.h"
|
|
#include "ui/windows/address_transfer_dialog.h"
|
|
#include "ui/windows/bootstrap_download_dialog.h"
|
|
#include "ui/windows/xmrig_download_dialog.h"
|
|
#include "ui/windows/daemon_download_dialog.h"
|
|
#include "ui/windows/wallets_dialog.h"
|
|
#include "ui/windows/console_tab.h"
|
|
#include "ui/pages/settings_page.h"
|
|
#include "ui/theme.h"
|
|
#include "ui/sidebar.h"
|
|
#include "ui/effects/imgui_acrylic.h"
|
|
#include "ui/effects/theme_effects.h"
|
|
#include "ui/effects/low_spec.h"
|
|
#include "ui/material/color_theme.h"
|
|
#include "ui/material/type.h"
|
|
#include "ui/material/typography.h"
|
|
#include "ui/material/draw_helpers.h"
|
|
#include "ui/widgets/copy_field.h"
|
|
#include "ui/notifications.h"
|
|
#include "util/i18n.h"
|
|
#include "util/platform.h"
|
|
#include "util/text_format.h"
|
|
#include "util/payment_uri.h"
|
|
#include "util/texture_loader.h"
|
|
#include "util/bootstrap.h"
|
|
#include "util/secure_vault.h"
|
|
#include "resources/embedded_resources.h"
|
|
#include "ui/schema/ui_schema.h"
|
|
#include "ui/schema/skin_manager.h"
|
|
#include "util/perf_log.h"
|
|
|
|
// Embedded ui.toml (generated at build time) — fallback when file not on disk
|
|
#if __has_include("ui_toml_embedded.h")
|
|
#include "ui_toml_embedded.h"
|
|
#define HAS_EMBEDDED_UI_TOML 1
|
|
#else
|
|
#define HAS_EMBEDDED_UI_TOML 0
|
|
#endif
|
|
|
|
#include "imgui.h"
|
|
#include <nlohmann/json.hpp>
|
|
#include <curl/curl.h>
|
|
|
|
#include <cstdio>
|
|
#include <ctime>
|
|
#include <algorithm>
|
|
#include <random>
|
|
#include <map>
|
|
#include <set>
|
|
#include <unordered_set>
|
|
#include <fstream>
|
|
#include <filesystem>
|
|
#include <thread>
|
|
#include <chrono>
|
|
#include <exception>
|
|
|
|
#ifdef _WIN32
|
|
#include <windows.h>
|
|
#include <shellapi.h>
|
|
#include "util/logger.h"
|
|
#endif
|
|
|
|
namespace dragonx {
|
|
|
|
using json = nlohmann::json;
|
|
|
|
App::App()
|
|
{
|
|
// Seed the auto-balance RNG once per run so weighted-random pool selection isn't
|
|
// deterministic across launches.
|
|
balance_rng_.seed(std::random_device{}());
|
|
}
|
|
App::~App()
|
|
{
|
|
// Scrub any seed/phrase secret still resident (e.g. app quit with a backup/migration modal open).
|
|
if (!seed_migration_seed_.empty())
|
|
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
|
|
if (!seed_backup_phrase_.empty())
|
|
sodium_memzero(&seed_backup_phrase_[0], seed_backup_phrase_.size());
|
|
}
|
|
|
|
namespace {
|
|
// How often auto-balance re-evaluates the pool while active. Long, because switching
|
|
// restarts the miner (drops in-flight shares + reconnect); the incumbent stickiness
|
|
// in chooseWeightedPool makes actual switches rarer still.
|
|
constexpr long long kRebalanceIntervalMs = 30LL * 60LL * 1000LL; // 30 minutes
|
|
|
|
long long steadyNowMs()
|
|
{
|
|
using namespace std::chrono;
|
|
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
|
|
}
|
|
} // namespace
|
|
|
|
void App::updatePoolAutoBalance()
|
|
{
|
|
if (!settings_) return;
|
|
if (!supportsPoolMining()) return; // pool mining is available in both builds (solo is full-node only)
|
|
if (settings_->getPoolSelectMode() != config::Settings::PoolSelectMode::AutoBalance) return;
|
|
if (!settings_->getPoolMode()) return; // only while POOL mode is selected
|
|
|
|
const long long now = steadyNowMs();
|
|
const bool intervalDue = (last_balance_eval_ms_ == 0) ||
|
|
(now - last_balance_eval_ms_ >= kRebalanceIntervalMs);
|
|
|
|
// Kick a background refresh when requested or due (never while one is in flight).
|
|
if ((balance_refresh_pending_ || intervalDue) && !pool_stats_service_.busy()) {
|
|
balance_refresh_pending_ = false;
|
|
last_balance_eval_ms_ = now;
|
|
balance_snapshot_seen_ = false;
|
|
pool_stats_service_.refresh(util::knownPools());
|
|
return; // results land on a later frame
|
|
}
|
|
|
|
// Apply the freshly-completed snapshot exactly once.
|
|
if (!pool_stats_service_.busy()) {
|
|
const auto snap = pool_stats_service_.snapshot();
|
|
if (snap.ready && !balance_snapshot_seen_) {
|
|
balance_snapshot_seen_ = true;
|
|
applyPoolAutoBalance(snap);
|
|
}
|
|
}
|
|
}
|
|
|
|
void App::applyPoolAutoBalance(const util::PoolStatsService::Snapshot& snap)
|
|
{
|
|
const auto& pools = util::knownPools();
|
|
|
|
std::vector<util::PoolHashrate> samples;
|
|
samples.reserve(pools.size());
|
|
for (const auto& kp : pools) {
|
|
const auto it = snap.byId.find(kp.id);
|
|
samples.push_back(it != snap.byId.end()
|
|
? it->second
|
|
: util::PoolHashrate{kp.id, 0.0, false});
|
|
}
|
|
|
|
const util::KnownPool* current = util::findKnownPoolByUrl(settings_->getPoolUrl());
|
|
const std::string currentId = current ? current->id : std::string{};
|
|
|
|
const std::string chosenId = util::chooseWeightedPool(samples, currentId, balance_rng_);
|
|
if (chosenId.empty() || chosenId == currentId) return; // keep the current pool
|
|
|
|
const util::KnownPool* chosen = nullptr;
|
|
for (const auto& kp : pools)
|
|
if (kp.id == chosenId) { chosen = &kp; break; }
|
|
if (!chosen) return;
|
|
|
|
settings_->setPoolUrl(chosen->stratum);
|
|
settings_->save();
|
|
|
|
ui::Notifications::instance().info(
|
|
std::string(TR("mining_auto_balanced_to")) + " " + chosen->label);
|
|
|
|
// Restart so the new pool (and its per-pool algo) takes effect immediately.
|
|
if (xmrig_manager_ && xmrig_manager_->isRunning()) {
|
|
const int threads = xmrig_manager_->getRequestedThreads();
|
|
stopPoolMining();
|
|
startPoolMining(threads);
|
|
}
|
|
}
|
|
|
|
std::string App::poolMiningInstalledVersion()
|
|
{
|
|
daemon::XmrigManager::startVersionDetection(); // idempotent one-shot
|
|
return daemon::XmrigManager::installedVersion();
|
|
}
|
|
|
|
// Console backend accessors — prefer the fast-lane RPC connection (so console commands
|
|
// don't queue behind the refresh batch), falling back to the main connection.
|
|
rpc::RPCClient* App::consoleRpc()
|
|
{
|
|
return (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
|
}
|
|
rpc::RPCWorker* App::consoleWorker()
|
|
{
|
|
return (fast_rpc_ && fast_rpc_->isConnected() && fast_worker_) ? fast_worker_.get() : worker_.get();
|
|
}
|
|
daemon::EmbeddedDaemon* App::consoleDaemon()
|
|
{
|
|
return daemon_controller_ ? daemon_controller_->daemon() : nullptr;
|
|
}
|
|
daemon::XmrigManager* App::consoleXmrig()
|
|
{
|
|
return xmrig_manager_.get();
|
|
}
|
|
|
|
bool App::sendStopCommandSafely(rpc::RPCClient& client, const char* context)
|
|
{
|
|
const char* label = context ? context : "App";
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Daemon lifecycle / Stop command");
|
|
client.call("stop");
|
|
DEBUG_LOGF("[%s] Stop command sent\n", label);
|
|
return true;
|
|
} catch (const std::exception& e) {
|
|
DEBUG_LOGF("[%s] Stop RPC failed: %s\n", label, e.what());
|
|
} catch (...) {
|
|
DEBUG_LOGF("[%s] Stop RPC failed with unknown exception\n", label);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
class AppDaemonLifecycleRuntime final : public daemon::DaemonController::LifecycleRuntime {
|
|
public:
|
|
explicit AppDaemonLifecycleRuntime(App& app) : app_(app) {}
|
|
|
|
void stopDaemonWithPolicy() override { app_.stopEmbeddedDaemon(); }
|
|
bool startDaemon() override { return app_.startEmbeddedDaemon(); }
|
|
void resetOutputOffset() override { app_.daemon_output_offset_ = 0; }
|
|
|
|
void requestRpcStopAndDisconnect(const char* context, const char* reason) override
|
|
{
|
|
if (app_.rpc_ && app_.rpc_->isConnected()) {
|
|
app_.sendStopCommandSafely(*app_.rpc_, context);
|
|
app_.rpc_->disconnect();
|
|
}
|
|
app_.onDisconnected(reason ? reason : "Daemon lifecycle");
|
|
}
|
|
|
|
int deleteBlockchainData() override
|
|
{
|
|
return daemon::BlockchainDataCleaner::removeBlockchainData(util::Platform::getDragonXDataDir());
|
|
}
|
|
|
|
private:
|
|
App& app_;
|
|
};
|
|
|
|
bool App::init()
|
|
{
|
|
DEBUG_LOGF("Initializing ObsidianDragon...\n");
|
|
|
|
// Extract embedded resources (Sapling params, asmap.dat) on first run
|
|
if (resources::hasEmbeddedResources() && resources::needsParamsExtraction()) {
|
|
DEBUG_LOGF("First run - extracting bundled resources...\n");
|
|
resources::extractEmbeddedResources();
|
|
}
|
|
|
|
// Initialize settings
|
|
settings_ = std::make_unique<config::Settings>();
|
|
if (!settings_->load()) {
|
|
DEBUG_LOGF("Warning: Could not load settings, using defaults\n");
|
|
}
|
|
// On upgrade (version mismatch), re-save to persist new defaults + current version
|
|
if (settings_->needsUpgradeSave()) {
|
|
DEBUG_LOGF("[INFO] Wallet upgraded — re-saving settings with new defaults\n");
|
|
settings_->save();
|
|
settings_->clearUpgradeSave();
|
|
}
|
|
|
|
// Lite builds with a linked SDXL backend own a lite wallet controller that drives
|
|
// real create/open/restore through the bridge. Full-node and unlinked-lite builds
|
|
// leave lite_wallet_ null (the UI falls back to validation-only).
|
|
rebuildLiteWallet();
|
|
|
|
// Apply verbose logging preference from saved settings
|
|
util::Logger::instance().setVerbose(settings_->getVerboseLogging());
|
|
|
|
// Restore console display preferences into the ConsoleTab statics at startup. These are the only
|
|
// startup restore for these toggles — the Settings page / first-run wizard also sync scanline, but
|
|
// only when visited, so without this an existing wallet would ignore a saved preference on launch.
|
|
ui::ConsoleTab::s_scanline_enabled = settings_->getScanlineEnabled();
|
|
ui::ConsoleTab::s_line_accents_enabled = settings_->getConsoleLineAccents();
|
|
ui::ConsoleTab::s_line_text_color_enabled = settings_->getConsoleTextColor();
|
|
ui::ConsoleTab::s_console_zoom = settings_->getConsoleZoom();
|
|
|
|
// Apply saved user font scale so fonts are correct on first reload
|
|
{
|
|
float fs = settings_->getFontScale();
|
|
if (fs > 1.0f) {
|
|
ui::Layout::setUserFontScale(fs);
|
|
// Fonts were loaded at default scale in Init(); rebuild now.
|
|
auto& typo = ui::material::Typography::instance();
|
|
ImGuiIO& io = ImGui::GetIO();
|
|
typo.reload(io, typo.getDpiScale());
|
|
// Consume the flag so App::update() doesn't double-reload
|
|
ui::Layout::consumeUserFontReload();
|
|
DEBUG_LOGF("App: Applied saved font scale %.1fx\n", fs);
|
|
}
|
|
}
|
|
|
|
// Ensure ObsidianDragon config directory and template files exist
|
|
util::Platform::ensureObsidianDragonSetup();
|
|
|
|
// Initialize PIN vault
|
|
vault_ = std::make_unique<util::SecureVault>();
|
|
|
|
// Theme is now applied via SkinManager below after UISchema loads.
|
|
// The old SetThemeById() C++ fallback is no longer needed at startup
|
|
// because SkinManager.setActiveSkin() loads colors from ui.toml directly.
|
|
|
|
// Initialize unified UI schema (loads TOML, drives all layout values)
|
|
{
|
|
std::string schemaPath = util::Platform::getExecutableDirectory() + "/res/themes/ui.toml";
|
|
bool loaded = false;
|
|
|
|
#if HAS_EMBEDDED_UI_TOML
|
|
// If on-disk ui.toml exists but differs in size from the embedded
|
|
// version, a newer wallet binary is running against stale theme
|
|
// files. Overwrite the on-disk copy so layout matches the binary.
|
|
if (std::filesystem::exists(schemaPath)) {
|
|
std::error_code ec;
|
|
auto diskSize = std::filesystem::file_size(schemaPath, ec);
|
|
if (!ec && diskSize != static_cast<std::uintmax_t>(embedded::ui_toml_size)) {
|
|
DEBUG_LOGF("[INFO] ui.toml on disk (%ju bytes) differs from embedded (%zu bytes) — updating\n",
|
|
(uintmax_t)diskSize, embedded::ui_toml_size);
|
|
std::ofstream ofs(schemaPath, std::ios::binary | std::ios::trunc);
|
|
if (ofs.is_open()) {
|
|
ofs.write(reinterpret_cast<const char*>(embedded::ui_toml_data),
|
|
embedded::ui_toml_size);
|
|
ofs.close();
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
if (std::filesystem::exists(schemaPath)) {
|
|
loaded = ui::schema::UISchema::instance().loadFromFile(schemaPath);
|
|
}
|
|
// Fallback: load from build-time embedded data when file not on disk
|
|
// (e.g., single-file Windows distribution without res/ directory)
|
|
if (!loaded) {
|
|
#if HAS_EMBEDDED_UI_TOML
|
|
std::string embedded(reinterpret_cast<const char*>(embedded::ui_toml_data),
|
|
embedded::ui_toml_size);
|
|
ui::schema::UISchema::instance().loadFromString(embedded, "embedded");
|
|
#else
|
|
DEBUG_LOGF("Warning: ui.toml not found at %s and no embedded fallback\n", schemaPath.c_str());
|
|
#endif
|
|
}
|
|
|
|
// Initialize SkinManager and activate saved skin
|
|
auto& skinMgr = ui::schema::SkinManager::instance();
|
|
skinMgr.refresh();
|
|
|
|
// Register image reload callback for skin changes
|
|
skinMgr.setImageReloadCallback([this](const std::string& bgPath, const std::string& logoPath) {
|
|
reloadThemeImages(bgPath, logoPath);
|
|
});
|
|
|
|
std::string skinId = settings_->getSkinId();
|
|
if (skinId.empty()) skinId = "dragonx";
|
|
|
|
// Apply gradient background preference before activating the skin
|
|
// so the initial image resolution uses the correct mode.
|
|
if (settings_->getGradientBackground()) {
|
|
skinMgr.setGradientMode(true);
|
|
}
|
|
|
|
skinMgr.setActiveSkin(skinId);
|
|
}
|
|
|
|
// Apply saved language
|
|
std::string lang = settings_->getLanguage();
|
|
if (!lang.empty()) {
|
|
util::I18n::instance().loadLanguage(lang);
|
|
}
|
|
|
|
// Initialize RPC client
|
|
rpc_ = std::make_unique<rpc::RPCClient>();
|
|
|
|
// Initialize background RPC worker thread
|
|
worker_ = std::make_unique<rpc::RPCWorker>();
|
|
worker_->start();
|
|
network_refresh_.markDue(services::NetworkRefreshService::Timer::Price);
|
|
|
|
// Warm the Mining tab's data at startup (both async / off the UI thread) so pool
|
|
// hashrates and the installed miner version are shown as soon as the tab is opened,
|
|
// instead of only after the user clicks a mine / auto-balance button.
|
|
if (supportsPoolMining()) {
|
|
daemon::XmrigManager::startVersionDetection(); // idempotent one-shot `xmrig --version`
|
|
pool_stats_service_.refresh(util::knownPools()); // snapshot lands on a later frame
|
|
}
|
|
|
|
// Forward error/warning notifications to the console tab. The channel is the semantic
|
|
// key — the Errors filter keys off ConsoleChannel::Error, and the color/accent bar are
|
|
// derived from the channel at draw time.
|
|
ui::Notifications::instance().setConsoleCallback(
|
|
[this](const std::string& msg, bool is_error) {
|
|
console_tab_.addLine(msg, is_error ? ui::ConsoleChannel::Error
|
|
: ui::ConsoleChannel::Warning);
|
|
});
|
|
|
|
// Forward all app-level log messages (DEBUG_LOGF, LOGF, etc.) to the
|
|
// console tab so they are visible in the UI, not just in the log file.
|
|
util::Logger::instance().setCallback(
|
|
[this](const std::string& msg) {
|
|
// Classify by content into a severity channel: errors, warnings, else the App
|
|
// channel (the routine wallet log stream, controlled by the App messages filter).
|
|
ui::ConsoleChannel channel = ui::ConsoleChannel::App;
|
|
if (msg.find("[ERROR]") != std::string::npos ||
|
|
msg.find("error") != std::string::npos ||
|
|
msg.find("Error") != std::string::npos ||
|
|
msg.find("failed") != std::string::npos ||
|
|
msg.find("Failed") != std::string::npos) {
|
|
channel = ui::ConsoleChannel::Error;
|
|
} else if (msg.find("[WARN]") != std::string::npos ||
|
|
msg.find("warn") != std::string::npos) {
|
|
channel = ui::ConsoleChannel::Warning;
|
|
}
|
|
// Strip trailing newline so console tab lines look clean. The App channel
|
|
// supplies the accent bar; no "[app] " text prefix needed.
|
|
std::string trimmed = msg;
|
|
while (!trimmed.empty() && (trimmed.back() == '\n' || trimmed.back() == '\r'))
|
|
trimmed.pop_back();
|
|
if (!trimmed.empty())
|
|
console_tab_.addLine(trimmed, channel);
|
|
});
|
|
|
|
// Check for first-run wizard — also re-run if blockchain data is missing
|
|
// even when wizard was previously completed (e.g. data dir was deleted).
|
|
// The wizard is full-node setup (daemon + blockchain); lite has neither, and isFirstRun()
|
|
// is always true in lite (no `blocks` dir), so skip it — lite users create/open a wallet in
|
|
// Settings, guided by the "No wallet open" prompt.
|
|
if (isFirstRun() && !isLiteBuild()) {
|
|
wizard_phase_ = WizardPhase::Appearance;
|
|
DEBUG_LOGF("First run detected — starting wizard\n");
|
|
// Don't start daemon yet — wait for wizard completion
|
|
} else {
|
|
// Normal startup — connect to daemon (tryConnect() is a no-op in lite builds)
|
|
tryConnect();
|
|
}
|
|
|
|
// Populate the shared contact store from disk (per-variant addressbook.json).
|
|
// Idempotent; a missing file is fine (returns true). Purely local — no daemon/RPC dependency.
|
|
address_book_.load();
|
|
|
|
// Load the wallet-files metadata index (wallets.json); populated after each wallet load. Local
|
|
// only, missing file is fine.
|
|
wallet_index_.load();
|
|
|
|
// If this wallet build bundles a newer daemon than the one already installed, offer to update it
|
|
// (once per version). Fixes the trap where a wallet update carrying a newer node silently kept
|
|
// the old binary — e.g. an old daemon lacking z_exportmnemonic blocking migrate-to-seed.
|
|
maybeOfferDaemonUpdate();
|
|
|
|
DEBUG_LOGF("Initialization complete\n");
|
|
return true;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Pre-frame: font atlas rebuilds (must run BEFORE ImGui::NewFrame)
|
|
// ============================================================================
|
|
void App::preFrame()
|
|
{
|
|
ImGuiIO& io = ImGui::GetIO();
|
|
|
|
// Hot-reload unified UI schema
|
|
{
|
|
PERF_SCOPE("PreFrame.SchemaHotReload");
|
|
ui::schema::UISchema::instance().pollForChanges();
|
|
ui::schema::UISchema::instance().applyIfDirty();
|
|
}
|
|
|
|
// Refresh the per-frame layout cache (reads TOML values once)
|
|
ui::Layout::beginFrame();
|
|
|
|
// Refresh balance layout config only when schema changes
|
|
{
|
|
static uint32_t s_balanceLayoutGen = 0;
|
|
uint32_t gen = ui::schema::UISchema::instance().generation();
|
|
if (gen != s_balanceLayoutGen) {
|
|
s_balanceLayoutGen = gen;
|
|
ui::RefreshBalanceLayoutConfig();
|
|
}
|
|
}
|
|
|
|
// If font sizes changed in the TOML, rebuild the font atlas
|
|
if (ui::schema::UISchema::instance().consumeFontsChanged()) {
|
|
auto& typo = ui::material::Typography::instance();
|
|
typo.reload(io, typo.getDpiScale());
|
|
DEBUG_LOGF("App: Font atlas rebuilt after hot-reload\n");
|
|
}
|
|
|
|
// If the user changed font scale in Settings, rebuild the font atlas
|
|
if (ui::Layout::consumeUserFontReload()) {
|
|
auto& typo = ui::material::Typography::instance();
|
|
typo.reload(io, typo.getDpiScale());
|
|
DEBUG_LOGF("App: Font atlas rebuilt after user font-scale change (%.1fx)\n",
|
|
ui::Layout::userFontScale());
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
// Resolve the lite-wallet rollout / kill-switch decision (see wallet/lite_rollout_policy.h).
|
|
// Local-only: reads the override + a stable per-install id from settings, an emergency env var,
|
|
// and a locally-cached manifest file (NO network fetch). Fail-open: a missing/invalid manifest
|
|
// leaves the wallet enabled.
|
|
wallet::LiteRolloutDecision resolveLiteRolloutDecision(config::Settings& settings)
|
|
{
|
|
using namespace dragonx::wallet;
|
|
LiteRolloutInputs inputs;
|
|
inputs.appVersion = DRAGONX_VERSION;
|
|
|
|
// Emergency kill-switch env var: any value other than empty/"0"/"false" disables.
|
|
if (const char* env = std::getenv("DRAGONX_LITE_KILL_SWITCH")) {
|
|
const std::string v = env;
|
|
inputs.killSwitchEnv = !v.empty() && v != "0" && v != "false";
|
|
}
|
|
|
|
inputs.override = liteRolloutOverrideFromString(settings.getLiteRolloutOverride());
|
|
|
|
// Stable per-install bucket source — generated once, persisted, never transmitted, no PII.
|
|
std::string installId = settings.getLiteInstallId();
|
|
if (installId.empty()) {
|
|
if (sodium_init() >= 0) {
|
|
unsigned char buf[16];
|
|
randombytes_buf(buf, sizeof(buf));
|
|
static const char kHex[] = "0123456789abcdef";
|
|
installId.reserve(sizeof(buf) * 2);
|
|
for (unsigned char c : buf) {
|
|
installId.push_back(kHex[c >> 4]);
|
|
installId.push_back(kHex[c & 0x0F]);
|
|
}
|
|
}
|
|
settings.setLiteInstallId(installId);
|
|
settings.save();
|
|
}
|
|
inputs.installBucket = liteRolloutBucketFromInstallId(installId);
|
|
|
|
// Local manifest cache next to settings.json — no network fetch (a signed remote fetcher can
|
|
// populate this later). Absent/unreadable -> fail-open.
|
|
try {
|
|
const std::filesystem::path cfgDir =
|
|
std::filesystem::path(config::Settings::getDefaultPath()).parent_path();
|
|
inputs.manifest = loadLiteRolloutManifestFromFile((cfgDir / "lite_rollout.json").string());
|
|
} catch (...) {
|
|
// leave manifest absent -> fail-open
|
|
}
|
|
|
|
const LiteRolloutDecision decision = evaluateLiteRollout(inputs);
|
|
if (!decision.allowed) {
|
|
DEBUG_LOGF("[lite-rollout] lite wallet gated OFF: %s — %s\n",
|
|
liteRolloutStatusName(decision.status), decision.message.c_str());
|
|
}
|
|
return decision;
|
|
}
|
|
|
|
// True when a z_sendmany failure means the wallet's shielded note data (witnesses/anchors) is
|
|
// out of sync with the chain — the symptom after a bootstrap/reindex without a full rescan. These
|
|
// are consensus/proof-build rejections, not user mistakes, and the only fix is a full rescan.
|
|
bool sendErrorNeedsRescan(const std::string& raw)
|
|
{
|
|
auto has = [&](const char* s) { return raw.find(s) != std::string::npos; };
|
|
return has("shielded-requirements-not-met") ||
|
|
has("shielded requirements not met") ||
|
|
has("missing sapling anchor") ||
|
|
has("Invalid sapling spend proof") ||
|
|
has("Invalid output proof") ||
|
|
has("bad-txns-sapling-") ||
|
|
has("anchor");
|
|
}
|
|
} // namespace
|
|
|
|
void App::rebuildLiteWallet(bool force)
|
|
{
|
|
if (!supportsLiteBackend() || !settings_) return;
|
|
// Don't tear down a live session unless forced: if a wallet is already open (and possibly
|
|
// mid-sync), the new server selection is already persisted to settings and will take effect
|
|
// the next time the controller is built (next launch, or before another wallet is opened).
|
|
// Rebuilding would discard the open wallet and its in-flight, uninterruptible sync — which is
|
|
// exactly what the Network tab's apply-immediately server switch wants (force=true). Replacing
|
|
// lite_wallet_ destroys the old controller; its destructor detaches the uninterruptible sync
|
|
// thread (which keeps the shared bridge alive) and only joins the short poll worker, so this
|
|
// does not block. The app's auto-open loop then reopens the wallet against the new server.
|
|
if (!force && lite_wallet_ && lite_wallet_->walletOpen()) return;
|
|
|
|
const auto liteConn = wallet::liteConnectionSettingsFromAppSettings(*settings_);
|
|
wallet::liteLog(std::string(force ? "Lite controller rebuilt" : "Lite controller built") +
|
|
" (preferred server: " + liteConn.stickyServerUrl + ")");
|
|
lite_wallet_ = wallet::LiteWalletController::createLinked(
|
|
walletCapabilities(), liteConn, resolveLiteRolloutDecision(*settings_));
|
|
lite_wallet_->setPersistCallback([this]() { settings_->save(); });
|
|
|
|
// The new controller starts closed. Re-arm the one-shot auto-open so the next update()
|
|
// tick reopens the existing wallet against the (possibly changed) server — otherwise a
|
|
// server switch from the Network tab, or a retry after a failed open, would rebuild the
|
|
// controller but never reopen, leaving a permanent "disconnected" state.
|
|
lite_autoopen_done_ = false;
|
|
lite_open_error_.clear();
|
|
|
|
// A rebuilt controller may back a different wallet (server switch / re-open); drop any chat
|
|
// identity + decrypted messages, lock the DB, and re-arm provisioning so it re-derives (and
|
|
// reloads the right wallet's history) from the newly opened wallet's seed.
|
|
chat_service_.clearIdentity();
|
|
chat_service_.store().clear();
|
|
chat_db_.lock();
|
|
chat_identity_provisioned_ = false;
|
|
chat_identity_fetch_in_flight_ = false;
|
|
chat_identity_unavailable_ = false;
|
|
}
|
|
|
|
void App::update()
|
|
{
|
|
PERF_SCOPE("Update.Total");
|
|
ImGuiIO& io = ImGui::GetIO();
|
|
|
|
// Full UI screenshot sweep: demo state is injected once and must stay frozen. Skip every live
|
|
// op (refresh/connect/pumps) so a real daemon can't clobber it — on Windows a running node's
|
|
// refresh set connected=false (CDB error) and blanked the Send/Receive/History tabs mid-sweep.
|
|
// capture_mode_ is only set for the offline full sweep, so the live tab-only sweep is unaffected.
|
|
if (capture_mode_) return;
|
|
|
|
// Track user interaction for auto-lock
|
|
if (io.MouseDelta.x != 0 || io.MouseDelta.y != 0 ||
|
|
io.MouseClicked[0] || io.MouseClicked[1] ||
|
|
io.InputQueueCharacters.Size > 0 ||
|
|
ImGui::IsAnyItemActive()) {
|
|
last_interaction_ = std::chrono::steady_clock::now();
|
|
}
|
|
|
|
// Drain completed RPC results back onto the main thread
|
|
if (worker_) {
|
|
worker_->drainResults();
|
|
}
|
|
if (fast_worker_) {
|
|
fast_worker_->drainResults();
|
|
}
|
|
|
|
// Apply any lite-wallet refresh the controller's background worker produced (main thread).
|
|
if (lite_wallet_) {
|
|
// Finalize any completed async open on the main thread (flips walletOpen / surfaces failure).
|
|
lite_wallet_->pumpAsyncOpen();
|
|
// Likewise finalize any completed async lifecycle request (Settings-page create/open/restore).
|
|
lite_wallet_->pumpLifecycleResult();
|
|
|
|
// Auto-open an existing wallet asynchronously with server failover (initialize_existing
|
|
// needs no passphrase — it just loads the file and contacts the server). Running it off
|
|
// the UI thread means an unreachable server never freezes startup, and trying the other
|
|
// default servers means one dead server no longer strands the wallet. Retried on an
|
|
// interval so a transient outage self-heals once a server comes back — and much sooner
|
|
// (a few seconds) when the failure was a server merely warming up (-28), which clears fast.
|
|
const double nowSecs = ImGui::GetTime();
|
|
const double retryInterval = lite_wallet_->lastOpenWasWarmup() ? 4.0 : 20.0;
|
|
if (!lite_wallet_->walletOpen() && !lite_wallet_->openInProgress() &&
|
|
lite_wallet_->walletExists() &&
|
|
(!lite_autoopen_done_ || nowSecs - lite_open_last_attempt_ > retryInterval)) {
|
|
lite_autoopen_done_ = true;
|
|
lite_open_last_attempt_ = nowSecs;
|
|
lite_wallet_->beginOpenExisting();
|
|
}
|
|
|
|
// Lite has no RPC daemon (tryConnect() is a no-op in lite builds), so derive the app's
|
|
// "online" state — which gates the wallet UI via isConnected() — from the lite wallet.
|
|
// A wallet is open only after a successful backend init against the lite server, so this
|
|
// is a non-blocking proxy for "lite backend operational".
|
|
state_.connected = lite_wallet_->walletOpen();
|
|
if (state_.connected) {
|
|
lite_open_error_.clear(); // opened successfully — clear any prior error
|
|
} else {
|
|
// Surface a failed open (e.g. server unreachable) once per distinct reason, so the
|
|
// disconnected state isn't silent.
|
|
const std::string& err = lite_wallet_->lastOpenError();
|
|
if (!err.empty() && err != lite_open_error_) {
|
|
lite_open_error_ = err;
|
|
ui::Notifications::instance().error(std::string("Wallet open failed: ") + err, 8.0f);
|
|
}
|
|
}
|
|
// Suppress the status bar's full-node connection-detail line in lite ("" and "Connected"
|
|
// are both hidden); the connected/no-wallet indicator + sync status convey lite state.
|
|
connection_status_ = state_.connected ? "Connected" : "";
|
|
|
|
wallet::LiteWalletAppRefreshModel liteModel;
|
|
if (lite_wallet_->takeRefreshedModel(liteModel)) {
|
|
wallet::applyLiteRefreshModelToWalletState(liteModel, state_);
|
|
// HushChat (lite): harvest chat memos from the refreshed transactions and thread them
|
|
// (no-op when the feature is off or no identity; the store dedups across refreshes).
|
|
ingestLiteChatMemos(liteModel);
|
|
}
|
|
// Deliver a completed async send/shield result to the waiting send_tab callback.
|
|
wallet::LiteBroadcastResult broadcast;
|
|
if (lite_wallet_->takeBroadcastResult(broadcast)) {
|
|
// Mirror failures into the lite Console (copyable) in addition to the toast the send UI
|
|
// shows — transient toasts are easy to miss and impossible to copy.
|
|
if (!broadcast.ok)
|
|
wallet::liteLog("Send/shield failed: " + broadcast.error);
|
|
if (lite_send_callback_) {
|
|
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error);
|
|
lite_send_callback_ = nullptr;
|
|
}
|
|
}
|
|
|
|
// Startup lock screen: once the first refresh reveals the (auto-opened) wallet is
|
|
// encrypted, prompt to unlock if it's locked. Soft by design — balances stay viewable via
|
|
// viewing keys while locked; only spending needs the passphrase, so the user may dismiss
|
|
// and browse read-only. Fires once per session.
|
|
if (!lite_startup_lock_checked_ && state_.encrypted) {
|
|
lite_startup_lock_checked_ = true;
|
|
if (state_.locked) requestLiteUnlock();
|
|
}
|
|
}
|
|
async_tasks_.reapCompleted();
|
|
|
|
// HushChat: once the wallet seed is reachable+unlocked, derive & set the chat identity so the
|
|
// transaction-refresh harvest can decrypt incoming memos. Cheap early-outs keep it idle until
|
|
// it can act; compiled away when the feature is off (constexpr gate inside).
|
|
maybeProvisionChatIdentity();
|
|
|
|
// One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
|
|
maybeRemindSeedBackup();
|
|
|
|
// Classify the wallet's mnemonic status (once per connect) so the Migrate-to-seed button can
|
|
// glow for a legacy, pre-seed-phrase wallet.
|
|
probeWalletSeedStatus();
|
|
|
|
// Pick up progress/result from a running seed-wallet migration (create/sweep/adopt).
|
|
pumpSeedMigration();
|
|
// While confirming the sweep, poll the tx confirmations + legacy balance every ~5s.
|
|
if (show_seed_migration_ && seed_migration_step_ == SeedMigrationStep::Confirming) {
|
|
seed_migration_poll_timer_ -= ImGui::GetIO().DeltaTime;
|
|
if (seed_migration_poll_timer_ <= 0.0f) {
|
|
seed_migration_poll_timer_ = 5.0f;
|
|
pollSweepStatus();
|
|
}
|
|
}
|
|
|
|
// Auto-lock check (only when connected + encrypted + unlocked)
|
|
if (state_.connected && state_.isUnlocked()) {
|
|
checkAutoLock();
|
|
}
|
|
|
|
// Mine-when-idle check (runs every frame, internally rate-limited by idle detection)
|
|
checkIdleMining();
|
|
|
|
// P8: Dedup rebuildAddressList — only rebuild once per frame
|
|
if (address_list_dirty_) {
|
|
address_list_dirty_ = false;
|
|
state_.rebuildAddressList();
|
|
}
|
|
|
|
using RefreshTimer = services::NetworkRefreshService::Timer;
|
|
network_refresh_.tick(io.DeltaTime);
|
|
|
|
// Wipe a secret (seed/private key) from the clipboard once its auto-clear delay elapses.
|
|
pumpSecretClipboardClear();
|
|
|
|
// Re-apply the refresh cadence when sync starts/finishes: while syncing we throttle polling to
|
|
// a low-impact profile so RPC contention doesn't slow block download (see applyRefreshPolicy).
|
|
if (state_.sync.syncing != refresh_policy_syncing_) {
|
|
applyRefreshPolicy(current_page_);
|
|
}
|
|
|
|
// Full-node RPC refreshes gate on ACTUAL RPC connectivity, not state_.connected. In lite
|
|
// builds state_.connected is the lite-wallet "online" proxy (true when a wallet is open, to
|
|
// enable the wallet UI), but there is no RPC daemon — so RPC polls (mining/balance/peers/txs)
|
|
// must key off rpc_ being genuinely connected or they'd fire and fail ("Not connected").
|
|
const bool rpcConnected = rpc_ && rpc_->isConnected();
|
|
|
|
// Fast refresh (mining stats + daemon memory) every second
|
|
// Skip when wallet is locked — no need to poll, and queued tasks
|
|
// would delay the PIN unlock worker task.
|
|
//
|
|
// Also enter this block when the embedded daemon is running even if RPC currently reads
|
|
// disconnected: during a long rescan the daemon holds cs_main, so getinfo times out and the
|
|
// socket is marked down — but the rescan/witness PROGRESS we parse comes from the daemon's
|
|
// stdout pipe (no RPC needed), and getrescaninfo still answers (it takes no lock). Without this,
|
|
// the daemon-output parser stopped exactly when a rescan started, so witness progress never
|
|
// surfaced. The RPC-dependent pollers inside stay individually gated (mining skips during
|
|
// warmup/rescan; the getrescaninfo poll only runs once rescanning is flagged).
|
|
if (network_refresh_.consumeDue(RefreshTimer::Fast)) {
|
|
const bool daemonAlive = daemon_controller_ && daemon_controller_->isRunning();
|
|
if ((rpcConnected || daemonAlive) && !state_.isLocked()) {
|
|
// Skip the mining poll while the daemon is in warmup (e.g. during -rescan). Otherwise
|
|
// getmininginfo is rejected with -28 ("Rescanning...") every second and floods the log.
|
|
// The rescan progress poll below still runs — that's how we track the warmup/rescan.
|
|
if (rpcConnected && !state_.warming_up && !runtime_rescan_active_) {
|
|
refreshMiningInfo();
|
|
}
|
|
|
|
// Poll getrescaninfo for rescan progress (if rescan flag is set)
|
|
// Use fast_rpc_ when available to avoid blocking on rpc_'s
|
|
// curl_mutex (which may be held by a long-running import).
|
|
// Suppressed during a runtime rescanblockchain: the daemon holds cs_main for the whole
|
|
// scan, so every poll would block, and completion is signalled by the rescan RPC callback.
|
|
if (state_.sync.rescanning && !runtime_rescan_active_ && fast_worker_ && !rescan_status_poll_in_progress_) {
|
|
auto* rescanRpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
|
rescan_status_poll_in_progress_ = true;
|
|
fast_worker_->post([this, rescanRpc]() -> rpc::RPCWorker::MainCb {
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Startup / Rescan monitor");
|
|
auto info = rescanRpc->call("getrescaninfo");
|
|
bool rescanning = info.value("rescanning", false);
|
|
float progress = 0.0f;
|
|
if (info.contains("rescan_progress") && info["rescan_progress"].is_string()) {
|
|
try { progress = std::stof(info["rescan_progress"].get<std::string>()) * 100.0f; } catch (...) {}
|
|
} else if (info.contains("rescan_progress") && info["rescan_progress"].is_number()) {
|
|
progress = info["rescan_progress"].get<float>() * 100.0f;
|
|
}
|
|
return [this, rescanning, progress]() {
|
|
rescan_status_poll_in_progress_ = false;
|
|
if (rescanning) {
|
|
state_.sync.rescanning = true;
|
|
rescan_confirmed_active_ = true;
|
|
if (progress > 0.0f) {
|
|
state_.sync.rescan_progress = progress / 100.0f;
|
|
}
|
|
} else if (state_.sync.rescanning && rescan_confirmed_active_) {
|
|
// Genuine completion: getrescaninfo answers cleanly AND we previously
|
|
// saw the rescan running. Without the confirmed-active gate, the first
|
|
// poll (which hits the still-running pre-restart daemon, rescanning=false)
|
|
// would fire a false "complete" the instant rescan was clicked.
|
|
ui::Notifications::instance().success("Blockchain rescan complete");
|
|
resetWitnessRescanProgress();
|
|
state_.sync.rescan_progress = 1.0f;
|
|
}
|
|
// else: rescanning=false but not yet confirmed → pre-restart daemon; keep waiting.
|
|
};
|
|
} catch (const std::exception& e) {
|
|
// During -rescan the daemon is in RPC warmup and rejects every call with the
|
|
// current phase as the message ("Loading block index..." → "Rescanning...").
|
|
// That's not a failure — it's proof the rescan is running. Surface it and mark
|
|
// the rescan confirmed-active so completion is recognised when warmup ends.
|
|
std::string phase = e.what();
|
|
return [this, phase]() {
|
|
rescan_status_poll_in_progress_ = false;
|
|
if (state_.sync.rescanning) {
|
|
rescan_confirmed_active_ = true;
|
|
state_.sync.rescan_status = phase;
|
|
}
|
|
};
|
|
} catch (...) {
|
|
return [this](){ rescan_status_poll_in_progress_ = false; };
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Poll xmrig stats every ~2 seconds (use a simple toggle)
|
|
static bool xmrig_poll_tick = false;
|
|
xmrig_poll_tick = !xmrig_poll_tick;
|
|
if (xmrig_poll_tick && xmrig_manager_ && xmrig_manager_->isRunning()) {
|
|
xmrig_manager_->pollStats();
|
|
auto& ps = state_.pool_mining;
|
|
auto& xs = xmrig_manager_->getStats();
|
|
ps.xmrig_running = true;
|
|
ps.hashrate_10s = xs.hashrate_10s;
|
|
ps.hashrate_60s = xs.hashrate_60s;
|
|
ps.hashrate_15m = xs.hashrate_15m;
|
|
ps.accepted = xs.accepted;
|
|
ps.rejected = xs.rejected;
|
|
ps.uptime_sec = xs.uptime_sec;
|
|
ps.pool_diff = xs.pool_diff;
|
|
ps.pool_url = xs.pool_url;
|
|
ps.algo = xs.algo;
|
|
ps.version = xs.version;
|
|
ps.connected = xs.connected;
|
|
// Pool mining has a connect delay — announce once it's actually connected/hashing.
|
|
if (pool_starting_.load(std::memory_order_relaxed) && (ps.connected || ps.hashrate_10s > 0.0)) {
|
|
pool_starting_.store(false, std::memory_order_relaxed);
|
|
ui::Notifications::instance().success("Pool miner connected and hashing.");
|
|
}
|
|
// Get memory directly from OS (more reliable than API)
|
|
double memMB = xmrig_manager_->getMemoryUsageMB();
|
|
ps.memory_used = static_cast<int64_t>(memMB * 1024.0 * 1024.0);
|
|
ps.threads_active = xs.threads_active;
|
|
ps.pool_hashrate = xs.pool_hashrate;
|
|
ps.log_lines = xmrig_manager_->getRecentLines(30);
|
|
|
|
// Record hashrate sample for the chart
|
|
ps.hashrate_history.push_back(ps.hashrate_10s);
|
|
if (ps.hashrate_history.size() > PoolMiningState::MAX_HISTORY) {
|
|
ps.hashrate_history.erase(ps.hashrate_history.begin());
|
|
}
|
|
} else if (xmrig_manager_ && !xmrig_manager_->isRunning()) {
|
|
state_.pool_mining.xmrig_running = false;
|
|
pool_starting_.store(false, std::memory_order_relaxed); // stopped / never came up
|
|
}
|
|
|
|
// Auto-balance: periodically re-pick the pool by hashrate (self-throttled).
|
|
updatePoolAutoBalance();
|
|
|
|
// Populate solo mining log lines from daemon output
|
|
if (daemon_controller_ && daemon_controller_->isRunning()) {
|
|
state_.mining.log_lines = daemon_controller_->recentLines(50);
|
|
}
|
|
|
|
// Check daemon output for rescan progress (offloaded to worker)
|
|
if (daemon_controller_ && daemon_controller_->isRunning()) {
|
|
std::string newOutput = daemon_controller_->outputSince(daemon_output_offset_);
|
|
if (!newOutput.empty()) {
|
|
// Parse inline on the main thread. It's cheap string scanning of a small incremental
|
|
// buffer, and keeping it OFF the worker is essential: during a rescan the worker can
|
|
// be blocked for minutes on a getrescaninfo call (it waits on cs_main while the daemon
|
|
// holds it for a witness rebuild). On the worker, the parse queued behind that and the
|
|
// rescan/witness progress froze. Inline, progress updates every frame regardless.
|
|
const auto scan = services::NetworkRefreshService::parseDaemonRescanOutput(std::move(newOutput));
|
|
|
|
// Apply results directly — we are already on the main thread.
|
|
const std::string& status = scan.lastStatus;
|
|
if (scan.finished) {
|
|
if (state_.sync.rescanning) {
|
|
ui::Notifications::instance().success("Blockchain rescan complete");
|
|
}
|
|
// Witness rebuild finishes with the rescan it's part of.
|
|
resetWitnessRescanProgress();
|
|
state_.sync.rescan_progress = 1.0f;
|
|
} else if (scan.foundWitness) {
|
|
// Witness rebuild is the tail phase of a rescan — keep rescanning set so
|
|
// the broader gating holds, but surface the witness-specific progress.
|
|
state_.sync.rescanning = true;
|
|
rescan_confirmed_active_ = true;
|
|
state_.sync.building_witnesses = true;
|
|
|
|
// Which sub-phase is this batch? A "<n> remaining" token means the cache
|
|
// walk (phase 2); otherwise per-tx "Setting Initial" lines are the initial
|
|
// pass (phase 1). The two INTERLEAVE during a rescan (the daemon does both
|
|
// per block), so the phase is UPGRADE-ONLY: once the cache walk is seen it
|
|
// never drops back to the initial pass. Otherwise an interleaved initial
|
|
// line would flip the phase back and reset the bar every batch (thrash).
|
|
int phase = state_.sync.witness_phase;
|
|
if (scan.witnessRemaining >= 0 || scan.witnessRebuilt) phase = 2;
|
|
else if (!scan.witnessTxids.empty() && phase < 2) phase = 1;
|
|
|
|
// Entering a higher sub-phase → reset its progress + accumulators so the
|
|
// bar restarts from 0 (the two phases have different scales). Upgrade-only,
|
|
// so this happens at most twice (→1, →2), never repeatedly.
|
|
if (phase != state_.sync.witness_phase) {
|
|
state_.sync.witness_phase = phase;
|
|
state_.sync.witness_progress = 0.0f;
|
|
state_.sync.witness_remaining = 0;
|
|
witness_rebuild_total_blocks_ = 0;
|
|
witness_seen_txids_.clear();
|
|
witness_total_txs_ = 0;
|
|
}
|
|
|
|
if (phase == 2) {
|
|
state_.sync.witness_remaining = scan.witnessRemaining;
|
|
float p = -1.0f;
|
|
if (scan.witnessRebuilt) {
|
|
// Parallel rebuild finished (it logs no progress) — snap to 100%.
|
|
p = 1.0f;
|
|
state_.sync.witness_remaining = 0;
|
|
} else if (scan.witnessReadTotal > 0) {
|
|
// New daemon: exact done/total. Use the reported total as the
|
|
// denominator directly (no peak anchoring) for a smooth 0..1 sweep.
|
|
int done = scan.witnessReadTotal - (scan.witnessRemaining >= 0 ? scan.witnessRemaining : 0);
|
|
p = static_cast<float>(done) / static_cast<float>(scan.witnessReadTotal);
|
|
} else if (scan.witnessRemaining >= 0) {
|
|
// Older daemon: bare "<n> remaining" with no total — anchor the % to
|
|
// the largest remaining seen (its first/largest value ≈ the full span).
|
|
if (scan.witnessRemaining > witness_rebuild_total_blocks_)
|
|
witness_rebuild_total_blocks_ = scan.witnessRemaining;
|
|
if (witness_rebuild_total_blocks_ > 0)
|
|
p = 1.0f - static_cast<float>(scan.witnessRemaining) /
|
|
static_cast<float>(witness_rebuild_total_blocks_);
|
|
}
|
|
if (p >= 0.0f) {
|
|
if (p > 1.0f) p = 1.0f;
|
|
if (p > state_.sync.witness_progress) // monotonic within the phase
|
|
state_.sync.witness_progress = p;
|
|
}
|
|
} else if (phase == 1) {
|
|
// Initial pass: count DISTINCT witnessed txs / total. The set only
|
|
// grows (dedups the daemon's double-prints), so it's monotonic; the
|
|
// raw "<i>" can't be used — it's an unordered-map slot that bounces.
|
|
if (scan.witnessTotalTxs > witness_total_txs_) witness_total_txs_ = scan.witnessTotalTxs;
|
|
for (const auto& txid : scan.witnessTxids) witness_seen_txids_.insert(txid);
|
|
if (witness_total_txs_ > 0 && !witness_seen_txids_.empty()) {
|
|
float p = static_cast<float>(witness_seen_txids_.size()) /
|
|
static_cast<float>(witness_total_txs_);
|
|
if (p > 1.0f) p = 1.0f;
|
|
if (p > state_.sync.witness_progress) // monotonic within the phase
|
|
state_.sync.witness_progress = p;
|
|
}
|
|
}
|
|
if (!status.empty()) {
|
|
state_.sync.rescan_status = status;
|
|
}
|
|
} else if (scan.foundRescan) {
|
|
state_.sync.rescanning = true;
|
|
// Reading "Still rescanning" straight from the daemon log is hard proof the
|
|
// rescan is genuinely running — confirm it so the getrescaninfo poll's
|
|
// completion check can fire even if it never caught a warmup error.
|
|
rescan_confirmed_active_ = true;
|
|
if (scan.rescanPct > 0.0f) {
|
|
state_.sync.rescan_progress = scan.rescanPct / 100.0f;
|
|
}
|
|
if (!status.empty()) {
|
|
state_.sync.rescan_status = status;
|
|
}
|
|
}
|
|
}
|
|
} else if (!daemon_controller_ || !daemon_controller_->isRunning()) {
|
|
// Clear rescan state if daemon is not running (but preserve during restart)
|
|
if (state_.sync.rescanning && state_.sync.rescan_progress >= 0.99f) {
|
|
state_.sync.rescanning = false;
|
|
state_.sync.rescan_status.clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Poll pending z_sendmany operations for completion (full-node opid flow; lite has none).
|
|
// Prefer the fast lane but fall back to the main worker (mirrors every other fast_worker_ user)
|
|
// so a torn-down/not-yet-restarted fast lane can't silently strand the poll on "Waiting for
|
|
// operation" — the symptom when fast_worker_ was stopped on reconnect and never came back.
|
|
rpc::RPCWorker* opidWorker = (fast_worker_ && fast_worker_->isRunning())
|
|
? fast_worker_.get() : worker_.get();
|
|
if (network_refresh_.isDue(RefreshTimer::Opid) && !pending_opids_.empty()
|
|
&& rpcConnected && opidWorker && !opid_poll_in_progress_) {
|
|
network_refresh_.reset(RefreshTimer::Opid);
|
|
auto opids = pending_opids_; // copy for worker thread
|
|
opid_poll_in_progress_ = true;
|
|
opidWorker->post([this, opids]() -> rpc::RPCWorker::MainCb {
|
|
auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
|
if (!rpc) return [this](){ opid_poll_in_progress_ = false; };
|
|
json result;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Send tab / Operation status");
|
|
// No per-opid filter: this daemon rejects z_getoperationstatus(["opid"]) with
|
|
// "JSON value is not an array as expected", which left completed sends stuck on
|
|
// "Waiting for operation". The no-arg form returns ALL operations;
|
|
// parseOperationStatusPoll() filters down to the opids we're tracking.
|
|
result = rpc->call("z_getoperationstatus", json::array());
|
|
} catch (...) {
|
|
return [this](){ opid_poll_in_progress_ = false; };
|
|
}
|
|
auto parsed = services::NetworkRefreshService::parseOperationStatusPoll(result, opids);
|
|
return [this, parsed = std::move(parsed)]() mutable {
|
|
opid_poll_in_progress_ = false;
|
|
|
|
// Successes: hand the real txid to any waiting send UI callback.
|
|
std::unordered_set<std::string> successfulOpids;
|
|
for (const auto& [opid, txid] : parsed.successTxidsByOpid) {
|
|
successfulOpids.insert(opid);
|
|
markPendingSendTransactionSucceeded(opid, txid);
|
|
send_txids_.insert(txid);
|
|
invokeSendResultCallback(opid, true, txid);
|
|
}
|
|
|
|
// Failures: route to the originating send UI when there is one (it shows
|
|
// its own error toast); otherwise surface a generic notification (this is
|
|
// how shield/merge/auto-shield failures become visible).
|
|
for (const auto& [opid, rawMsg] : parsed.failureByOpid) {
|
|
// Auto-work-around the daemon's note-selection fee-gap: re-issues the send with a
|
|
// self-output so it covers the fee. If a retry was issued, defer the outcome to it.
|
|
if (maybeRetrySendForFeeGap(opid, rawMsg)) {
|
|
continue;
|
|
}
|
|
std::string msg = sendErrorNeedsRescan(rawMsg)
|
|
? rawMsg + "\n\n" + TR("send_err_needs_rescan")
|
|
: rawMsg;
|
|
if (!invokeSendResultCallback(opid, false, msg)) {
|
|
ui::Notifications::instance().error(msg);
|
|
}
|
|
}
|
|
|
|
std::vector<std::string> terminalOpids = std::move(parsed.doneOpids);
|
|
terminalOpids.insert(terminalOpids.end(),
|
|
parsed.staleOpids.begin(), parsed.staleOpids.end());
|
|
for (const auto& id : terminalOpids) {
|
|
pending_opids_.erase(
|
|
std::remove(pending_opids_.begin(), pending_opids_.end(), id),
|
|
pending_opids_.end());
|
|
}
|
|
|
|
// Stale opids (no longer reported by the daemon): let any waiting send UI
|
|
// know the outcome couldn't be confirmed rather than spinning forever.
|
|
for (const auto& opid : parsed.staleOpids) {
|
|
invokeSendResultCallback(opid, false, TR("send_status_unconfirmed"));
|
|
}
|
|
|
|
if (parsed.anySuccess) {
|
|
std::vector<std::string> successOpids;
|
|
std::vector<std::string> failedOrStaleOpids;
|
|
for (const auto& opid : terminalOpids) {
|
|
if (successfulOpids.find(opid) != successfulOpids.end()) successOpids.push_back(opid);
|
|
else failedOrStaleOpids.push_back(opid);
|
|
}
|
|
removePendingSendTransactions(successOpids, false);
|
|
removePendingSendTransactions(failedOrStaleOpids, true);
|
|
// Transaction confirmed by daemon — force immediate data refresh
|
|
transactions_dirty_ = true;
|
|
addresses_dirty_ = true;
|
|
last_tx_block_height_ = -1;
|
|
network_refresh_.markWalletMutationRefresh();
|
|
} else {
|
|
removePendingSendTransactions(terminalOpids, true);
|
|
maybeFinishTransactionSendProgress();
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
// After a bootstrap the preserved wallet.dat has stale spent-state and must be reconciled
|
|
// against the freshly-imported chain, or the first send tries to spend already-spent notes and
|
|
// is rejected. The -rescan restart can't do it (the snapshot lacks pre-snapshot blocks), so we
|
|
// detect the lowest available block height and run a runtime rescanblockchain from there. Wait
|
|
// until the daemon is connected and out of warmup so the probe and rescan can actually run.
|
|
if (post_bootstrap_rescan_pending_ && rpcConnected && state_.connected &&
|
|
!state_.warming_up && !state_.isLocked() && !state_.sync.rescanning &&
|
|
!runtime_rescan_active_ && !bootstrap_downloading_ &&
|
|
state_.sync.blocks > 1) { // wait until the tip is known so the probe has a real range
|
|
post_bootstrap_rescan_pending_ = false;
|
|
ui::Notifications::instance().info("Bootstrap complete — reconciling your wallet with the new chain data.");
|
|
detectLowestAvailableBlockHeight([this](bool ok, int lowest, bool fullHistory) {
|
|
if (ok && !fullHistory) {
|
|
runtimeRescan(lowest); // bootstrapped/pruned: rescan from the snapshot base
|
|
} else {
|
|
rescanBlockchain(); // full-history node (or probe failed): -rescan restart works
|
|
}
|
|
});
|
|
}
|
|
|
|
// Per-category refresh with tab-aware intervals
|
|
// Skip when wallet is locked — same reason as above.
|
|
if (rpcConnected && !state_.isLocked()) {
|
|
const bool walletDataPage = currentPageNeedsWalletDataRefresh();
|
|
if (!runtime_rescan_active_ && network_refresh_.consumeDue(RefreshTimer::Core)) {
|
|
refreshCoreData();
|
|
}
|
|
// Skip balance/tx/address refresh during warmup — RPC calls fail with -28 — and during a
|
|
// runtime rescan, when the daemon holds cs_main and these calls would block.
|
|
if (!state_.warming_up && !runtime_rescan_active_) {
|
|
if (network_refresh_.consumeDue(RefreshTimer::Transactions)) {
|
|
if (shouldRunWalletTransactionRefresh() && shouldRefreshTransactions()) {
|
|
refreshTransactionData();
|
|
} else if (walletDataPage && shouldRefreshRecentTransactions()) {
|
|
refreshRecentTransactionData();
|
|
}
|
|
}
|
|
if (network_refresh_.consumeDue(RefreshTimer::Addresses)) {
|
|
if (walletDataPage || addresses_dirty_ || hasTransactionSendProgress()) {
|
|
refreshAddressData();
|
|
}
|
|
}
|
|
}
|
|
if (network_refresh_.consumeDue(RefreshTimer::Peers)) {
|
|
refreshPeerInfo();
|
|
}
|
|
} else if (network_refresh_.consumeDue(RefreshTimer::Core)) {
|
|
if (!connection_in_progress_ &&
|
|
wizard_phase_ == WizardPhase::None &&
|
|
!bootstrap_downloading_) {
|
|
tryConnect();
|
|
}
|
|
}
|
|
|
|
// Price refresh every 60 seconds
|
|
if (network_refresh_.consumeDue(RefreshTimer::Price)) {
|
|
if (settings_->getFetchPrices()) {
|
|
refreshPrice();
|
|
refreshMarketChart(); // self-throttled to ~30 min; keeps sparkline history fresh
|
|
}
|
|
}
|
|
|
|
handleGlobalShortcuts();
|
|
}
|
|
|
|
void App::handleGlobalShortcuts()
|
|
{
|
|
ImGuiIO& io = ImGui::GetIO();
|
|
|
|
// Keyboard shortcut: Ctrl+, to open Settings page
|
|
if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_Comma)) {
|
|
setCurrentPage(ui::NavPage::Settings);
|
|
}
|
|
|
|
// Keyboard shortcut: Ctrl+Left/Right to cycle themes
|
|
if (io.KeyCtrl && !io.WantTextInput) {
|
|
bool prevTheme = ImGui::IsKeyPressed(ImGuiKey_LeftArrow);
|
|
bool nextTheme = ImGui::IsKeyPressed(ImGuiKey_RightArrow);
|
|
if (prevTheme || nextTheme) {
|
|
auto& skinMgr = ui::schema::SkinManager::instance();
|
|
const auto& skins = skinMgr.available();
|
|
if (!skins.empty()) {
|
|
int cur = 0;
|
|
for (int i = 0; i < (int)skins.size(); i++) {
|
|
if (skins[i].id == skinMgr.activeSkinId()) { cur = i; break; }
|
|
}
|
|
if (prevTheme)
|
|
cur = (cur - 1 + (int)skins.size()) % (int)skins.size();
|
|
else
|
|
cur = (cur + 1) % (int)skins.size();
|
|
skinMgr.setActiveSkin(skins[cur].id);
|
|
if (settings_) {
|
|
settings_->setSkinId(skins[cur].id);
|
|
settings_->save();
|
|
}
|
|
ui::Notifications::instance().info("Theme: " + skins[cur].name);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keyboard shortcut: F5 to refresh
|
|
if (ImGui::IsKeyPressed(ImGuiKey_F5)) {
|
|
refreshNow();
|
|
}
|
|
|
|
// Keyboard shortcut: Ctrl+Shift+Down to toggle low-spec mode
|
|
// (checked BEFORE Ctrl+Down so it doesn't also trigger theme effects)
|
|
if (io.KeyCtrl && io.KeyShift && !io.WantTextInput && ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
|
|
bool newLow = !ui::effects::isLowSpecMode();
|
|
ui::effects::setLowSpecMode(newLow);
|
|
if (newLow) {
|
|
// Disable all heavy effects at runtime (don't overwrite saved prefs)
|
|
ui::effects::ImGuiAcrylic::ApplyBlurAmount(0.0f);
|
|
ui::effects::ImGuiAcrylic::SetUIOpacity(1.0f);
|
|
ui::effects::ThemeEffects::instance().setEnabled(false);
|
|
ui::effects::ThemeEffects::instance().setReducedTransparency(true);
|
|
if (settings_) {
|
|
settings_->setLowSpecMode(true);
|
|
settings_->setWindowOpacity(1.0f);
|
|
settings_->save();
|
|
}
|
|
} else {
|
|
// Restore effect settings from saved preferences
|
|
if (settings_) {
|
|
settings_->setLowSpecMode(false);
|
|
settings_->save();
|
|
ui::effects::ImGuiAcrylic::ApplyBlurAmount(settings_->getBlurMultiplier());
|
|
ui::effects::ImGuiAcrylic::SetUIOpacity(settings_->getUIOpacity());
|
|
ui::effects::ThemeEffects::instance().setEnabled(settings_->getThemeEffectsEnabled());
|
|
ui::effects::ThemeEffects::instance().setReducedTransparency(!settings_->getThemeEffectsEnabled());
|
|
}
|
|
}
|
|
ui::Notifications::instance().info(newLow ? "Low-spec mode enabled" : "Low-spec mode disabled");
|
|
}
|
|
|
|
// Keyboard shortcut: Ctrl+Down to toggle theme effects (Shift excluded)
|
|
else if (io.KeyCtrl && !io.KeyShift && !io.WantTextInput && ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
|
|
bool newState = !ui::effects::ThemeEffects::instance().isEnabled();
|
|
ui::effects::ThemeEffects::instance().setEnabled(newState);
|
|
ui::effects::ThemeEffects::instance().setReducedTransparency(!newState);
|
|
if (settings_) {
|
|
settings_->setThemeEffectsEnabled(newState);
|
|
settings_->save();
|
|
}
|
|
ui::Notifications::instance().info(newState ? "Theme effects enabled" : "Theme effects disabled");
|
|
}
|
|
|
|
// Keyboard shortcut: Ctrl+Up to toggle simple gradient background
|
|
if (io.KeyCtrl && !io.KeyShift && !io.WantTextInput && ImGui::IsKeyPressed(ImGuiKey_UpArrow)) {
|
|
bool newGrad = !settings_->getGradientBackground();
|
|
settings_->setGradientBackground(newGrad);
|
|
ui::schema::SkinManager::instance().setGradientMode(newGrad);
|
|
settings_->save();
|
|
ui::Notifications::instance().info(newGrad ? "Simple background enabled" : "Simple background disabled");
|
|
}
|
|
|
|
// Debug: Ctrl+Shift+W to re-show first-run wizard (set to false to disable)
|
|
constexpr bool ENABLE_WIZARD_HOTKEY = false;
|
|
if constexpr (ENABLE_WIZARD_HOTKEY) {
|
|
if (io.KeyCtrl && io.KeyShift && ImGui::IsKeyPressed(ImGuiKey_W)) {
|
|
wizard_phase_ = WizardPhase::Appearance;
|
|
DEBUG_LOGF("[Debug] Wizard re-opened via Ctrl+Shift+W\n");
|
|
}
|
|
}
|
|
}
|
|
|
|
void App::render()
|
|
{
|
|
// Advance the screenshot sweep FIRST — before the first-run-wizard early-return below — so the
|
|
// sweep keeps progressing (and re-forces its target's state) even while a wizard step is shown.
|
|
// (Pins current_page_ too, before the sidebar reads it further down.)
|
|
updateScreenshotSweep();
|
|
|
|
// First-run wizard gate — blocks all normal UI
|
|
if (wizard_phase_ != WizardPhase::None && wizard_phase_ != WizardPhase::Done) {
|
|
renderFirstRunWizard();
|
|
return;
|
|
}
|
|
|
|
// Handle wizard completion — start daemon and connect
|
|
if (wizard_phase_ == WizardPhase::Done) {
|
|
wizard_phase_ = WizardPhase::None;
|
|
if (!state_.connected) {
|
|
if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
|
|
startEmbeddedDaemon();
|
|
}
|
|
tryConnect();
|
|
}
|
|
settings_->setWizardCompleted(true);
|
|
settings_->save();
|
|
}
|
|
|
|
// Process deferred encryption from wizard (runs in background)
|
|
processDeferredEncryption();
|
|
|
|
// Surface the blockchain-delete completion on the main thread (the worker can't touch Notifications).
|
|
{
|
|
int deletedN = pending_delete_result_.exchange(-1, std::memory_order_relaxed);
|
|
if (deletedN >= 0) {
|
|
ui::Notifications::instance().success("Blockchain data deleted (" + std::to_string(deletedN) +
|
|
" items). The daemon is restarting to re-sync from the network.");
|
|
}
|
|
}
|
|
|
|
|
|
// While any full-window BLUR overlay is open: (1) suppress panel theme-effects for the whole
|
|
// frame so their foreground-draw-list borders (sidebar included) don't bleed over the overlay;
|
|
// (2) mark a full-window blur overlay active so glass panels use their opaque fallback (they're
|
|
// covered by the overlay backdrop, and this leaves the backdrop as the sole acrylic blur caller).
|
|
// Driven by the shared framework's last-frame latch (modal-agnostic; timing-equivalent to the old
|
|
// PortfolioEditorActive() signal). RAII-restored to the prior state on any return path.
|
|
struct ModalRenderGuard {
|
|
bool fxOn, wasEffects;
|
|
explicit ModalRenderGuard(bool modalOpen) {
|
|
wasEffects = ui::effects::ThemeEffects::instance().isEnabled();
|
|
fxOn = modalOpen && wasEffects;
|
|
if (fxOn) ui::effects::ThemeEffects::instance().setEnabled(false);
|
|
ui::material::SetFullWindowBlurOverlayActive(modalOpen);
|
|
}
|
|
~ModalRenderGuard() {
|
|
if (fxOn) ui::effects::ThemeEffects::instance().setEnabled(true);
|
|
ui::material::SetFullWindowBlurOverlayActive(false);
|
|
}
|
|
} modalGuard(ui::material::AnyBlurOverlayActiveLastFrame());
|
|
|
|
// Main content area - use full window (no menu bar)
|
|
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
|
ImGui::SetNextWindowPos(viewport->WorkPos);
|
|
ImGui::SetNextWindowSize(viewport->WorkSize);
|
|
|
|
ImGuiWindowFlags window_flags = ImGuiWindowFlags_NoTitleBar |
|
|
ImGuiWindowFlags_NoResize |
|
|
ImGuiWindowFlags_NoMove |
|
|
ImGuiWindowFlags_NoCollapse |
|
|
ImGuiWindowFlags_NoBringToFrontOnFocus |
|
|
ImGuiWindowFlags_NoSavedSettings |
|
|
ImGuiWindowFlags_NoScrollbar |
|
|
ImGuiWindowFlags_NoScrollWithMouse;
|
|
|
|
// When OS backdrop is active, use NoBackground so DWM Mica/Acrylic shows through
|
|
if (ui::material::IsBackdropActive()) {
|
|
window_flags |= ImGuiWindowFlags_NoBackground;
|
|
}
|
|
|
|
// Main window padding from ui.toml schema (DPI-scaled)
|
|
const float hdp = ui::Layout::dpiScale();
|
|
const auto& mwWin = ui::schema::UI().window("components.main-window");
|
|
const float mainPadX = (mwWin.padding[0] > 0.0f ? mwWin.padding[0] : 16.0f) * hdp;
|
|
const float mainPadTop = (mwWin.padding[1] > 0.0f ? mwWin.padding[1] : 42.0f) * hdp;
|
|
const float mainPadBot = ui::schema::UI().drawElement("components.main-window", "padding-bottom").size * hdp;
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(mainPadX, mainPadTop));
|
|
ImGui::Begin("MainContent", nullptr, window_flags);
|
|
ImGui::PopStyleVar(); // WindowPadding — applied to the window, safe to pop now
|
|
|
|
// ---- Top-left branding: logo + "ObsidianDragon" title ----
|
|
// Drawn via DrawList in the top padding area — zero layout impact.
|
|
{
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
ImVec2 winPos = ImGui::GetWindowPos();
|
|
const auto& S = ui::schema::UI();
|
|
auto hdrElem = S.drawElement("components.main-window", "header-title");
|
|
auto hdrLabel = S.label("components.main-window", "header-title");
|
|
|
|
// Helper to read extraFloats with fallback (DPI-scaled)
|
|
auto hdrF = [&](const char* key, float fb) -> float {
|
|
auto it = hdrElem.extraFloats.find(key);
|
|
return ((it != hdrElem.extraFloats.end()) ? it->second : fb) * hdp;
|
|
};
|
|
|
|
const float brandPadX = hdrF("pad-x", mainPadX / hdp);
|
|
const float brandPadY = hdrF("pad-y", 8.0f);
|
|
const float logoGap = hdrF("logo-gap", 8.0f);
|
|
const float brandOffY = hdrF("offset-y", 0.0f);
|
|
const float hdrOpacity = (hdrElem.opacity >= 0.0f) ? hdrElem.opacity : 0.7f;
|
|
|
|
// Logo
|
|
float logoSize = mainPadTop - brandPadY * 2.0f; // fit within header
|
|
if (logoSize < 16.0f * hdp) logoSize = 16.0f * hdp;
|
|
float logoX = winPos.x + brandPadX;
|
|
float logoY = winPos.y + brandPadY + brandOffY;
|
|
if (logo_tex_ != 0) {
|
|
float aspect = (logo_h_ > 0) ? (float)logo_w_ / (float)logo_h_ : 1.0f;
|
|
float logoW = logoSize * aspect;
|
|
dl->AddImage(logo_tex_,
|
|
ImVec2(logoX, logoY),
|
|
ImVec2(logoX + logoW, logoY + logoSize));
|
|
logoX += logoW + logoGap;
|
|
}
|
|
|
|
// Title text — font and size from schema (DPI-scaled)
|
|
ImFont* titleFont = S.resolveFont(hdrLabel.font);
|
|
if (!titleFont) titleFont = ui::material::Type().subtitle1();
|
|
// TOML size is in logical pixels; scale by DPI. Font's LegacySize
|
|
// is already DPI-scaled from the atlas reload.
|
|
float titleFontSize = hdrElem.size >= 0.0f
|
|
? hdrElem.size * hdp
|
|
: titleFont->LegacySize;
|
|
if (titleFont) {
|
|
float textY = winPos.y + (mainPadTop - titleFontSize) * 0.5f + brandOffY;
|
|
ImU32 textCol = ui::material::OnSurface();
|
|
// Apply header text opacity
|
|
int a = (int)((float)((textCol >> 24) & 0xFF) * hdrOpacity);
|
|
textCol = (textCol & 0x00FFFFFF) | ((ImU32)a << 24);
|
|
|
|
// Base brand word in a heavier (Ubuntu Medium) weight as the "semibold" —
|
|
// the project ships no true 600 face. On the lite build the "Lite" suffix is
|
|
// appended in the Regular title font.
|
|
ImFont* baseFont = ui::material::Type().subtitle2(); // Ubuntu Medium
|
|
if (!baseFont) baseFont = titleFont;
|
|
const char* baseWord = "ObsidianDragon";
|
|
dl->AddText(baseFont, titleFontSize, ImVec2(logoX, textY), textCol, baseWord);
|
|
#if DRAGONX_LITE_BUILD
|
|
float baseW = baseFont->CalcTextSizeA(titleFontSize, FLT_MAX, 0.0f, baseWord).x;
|
|
dl->AddText(titleFont, titleFontSize, ImVec2(logoX + baseW, textY), textCol, "Lite");
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// Sidebar + Content layout
|
|
const float dp = ui::Layout::dpiScale();
|
|
auto sbde = [dp](const char* key, float fb) {
|
|
float v = ui::schema::UI().drawElement("components.sidebar", key).size;
|
|
return (v >= 0 ? v : fb) * dp;
|
|
};
|
|
float statusBarH = ui::schema::UI().window("components.status-bar").height;
|
|
if (statusBarH <= 0.0f) statusBarH = 24.0f; // safety fallback
|
|
// Content area padding from ui.toml schema
|
|
const auto& caWin = ui::schema::UI().window("components.content-area");
|
|
const float caMarginTop = ui::schema::UI().drawElement("components.content-area", "margin-top").size;
|
|
const float caMarginBot = ui::schema::UI().drawElement("components.content-area", "margin-bottom").size;
|
|
const float contentH = ImGui::GetContentRegionAvail().y - statusBarH - ImGui::GetStyle().ItemSpacing.y
|
|
- caMarginTop - caMarginBot;
|
|
|
|
// Auto-collapse when viewport is narrow (skip if user manually toggled)
|
|
float vpW = viewport->WorkSize.x;
|
|
float collapseHysteresis = ui::schema::UI().drawElement("components.main-window", "collapse-hysteresis").sizeOr(60.0f) * dp;
|
|
const float autoCollapseThreshold = sbde("auto-collapse-threshold", 800.0f);
|
|
if (!sidebar_user_toggled_) {
|
|
if (vpW < autoCollapseThreshold && !sidebar_collapsed_) {
|
|
sidebar_collapsed_ = true;
|
|
} else if (vpW >= autoCollapseThreshold + collapseHysteresis && sidebar_collapsed_) {
|
|
sidebar_collapsed_ = false;
|
|
}
|
|
} else {
|
|
// Reset manual override when viewport crosses the threshold significantly,
|
|
// so auto-collapse resumes after the user resizes the window
|
|
if ((!sidebar_collapsed_ && vpW >= autoCollapseThreshold + collapseHysteresis) ||
|
|
(sidebar_collapsed_ && vpW < autoCollapseThreshold)) {
|
|
sidebar_user_toggled_ = false;
|
|
}
|
|
}
|
|
|
|
// Animate sidebar width
|
|
float targetW = sidebar_collapsed_ ? sbde("collapsed-width", 64.0f) : sbde("width", 160.0f);
|
|
// On DPI change, snap instantly instead of animating from stale value
|
|
bool dpiChanged = (prev_dpi_scale_ > 0.0f && std::abs(dp - prev_dpi_scale_) > 0.01f);
|
|
prev_dpi_scale_ = dp;
|
|
if (sidebar_width_anim_ <= 0.0f || dpiChanged) sidebar_width_anim_ = targetW;
|
|
{
|
|
float diff = targetW - sidebar_width_anim_;
|
|
float dt = ImGui::GetIO().DeltaTime;
|
|
float t = dt * sbde("collapse-anim-speed", 10.0f);
|
|
if (t > 1.0f) t = 1.0f;
|
|
sidebar_width_anim_ += diff * t;
|
|
}
|
|
const float sidebarW = sidebar_width_anim_;
|
|
|
|
// Build sidebar status for badges + footer
|
|
ui::SidebarStatus sbStatus;
|
|
sbStatus.peerCount = static_cast<int>(state_.peers.size());
|
|
sbStatus.miningActive = state_.mining.generate || state_.pool_mining.xmrig_running;
|
|
|
|
// Load logo texture lazily on first frame (or after theme change)
|
|
// Also reload when dark↔light mode changes so the correct variant shows
|
|
{
|
|
bool wantDark = ui::material::IsDarkTheme();
|
|
if (!logo_loaded_ || (wantDark != logo_is_dark_variant_)) {
|
|
logo_loaded_ = true;
|
|
logo_is_dark_variant_ = wantDark;
|
|
logo_tex_ = 0; logo_w_ = 0; logo_h_ = 0;
|
|
|
|
// 1) Check for theme-override logo from active skin
|
|
const auto* activeSkin = ui::schema::SkinManager::instance().findById(
|
|
ui::schema::SkinManager::instance().activeSkinId());
|
|
std::string logoPath;
|
|
if (activeSkin && !activeSkin->logoPath.empty()) {
|
|
logoPath = activeSkin->logoPath;
|
|
} else {
|
|
// 2) Read icon filename from ui.toml (dark/light variant)
|
|
auto iconElem = ui::schema::UI().drawElement("components.main-window", "header-icon");
|
|
const char* iconKey = wantDark ? "icon-dark" : "icon-light";
|
|
auto it = iconElem.extraColors.find(iconKey);
|
|
std::string iconFile;
|
|
if (it != iconElem.extraColors.end() && !it->second.empty()) {
|
|
iconFile = it->second;
|
|
} else {
|
|
// Fallback filenames
|
|
iconFile = wantDark ? "logos/logo_ObsidianDragon_dark.png" : "logos/logo_ObsidianDragon_light.png";
|
|
}
|
|
logoPath = util::getExecutableDirectory() + "/res/img/" + iconFile;
|
|
}
|
|
// Only attempt the disk read when the file is actually present (dev build / theme drop-in).
|
|
// The portable single-file build has no res/img/ beside it, so skip straight to the
|
|
// embedded copy instead of logging a spurious "failed to read".
|
|
std::error_code logoEc;
|
|
if (std::filesystem::exists(logoPath, logoEc) &&
|
|
util::LoadTextureFromFile(logoPath.c_str(), &logo_tex_, &logo_w_, &logo_h_)) {
|
|
DEBUG_LOGF("Loaded header logo from %s (%dx%d)\n", logoPath.c_str(), logo_w_, logo_h_);
|
|
} else {
|
|
// Try embedded data fallback — use actual filename from path
|
|
// so light/dark variants resolve correctly on Windows single-file
|
|
std::string embeddedName = std::filesystem::path(logoPath).filename().string();
|
|
const auto* logoRes = resources::getEmbeddedResource(embeddedName);
|
|
if (!logoRes || !logoRes->data || logoRes->size == 0) {
|
|
// Final fallback: try the default dark logo constant
|
|
logoRes = resources::getEmbeddedResource(resources::RESOURCE_LOGO);
|
|
}
|
|
if (logoRes && logoRes->data && logoRes->size > 0) {
|
|
if (util::LoadTextureFromMemory(logoRes->data, logoRes->size, &logo_tex_, &logo_w_, &logo_h_)) {
|
|
DEBUG_LOGF("Loaded header logo from embedded: %s (%dx%d)\n", embeddedName.c_str(), logo_w_, logo_h_);
|
|
} else {
|
|
DEBUG_LOGF("Note: Failed to decode embedded logo (text-only header)\n");
|
|
}
|
|
} else {
|
|
DEBUG_LOGF("Note: Header logo not found at %s (text-only header)\n", logoPath.c_str());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load coin logo texture lazily (DragonX currency icon for balance tab)
|
|
if (!coin_logo_loaded_) {
|
|
coin_logo_loaded_ = true;
|
|
coin_logo_tex_ = 0; coin_logo_w_ = 0; coin_logo_h_ = 0;
|
|
|
|
// Read coin icon filename from ui.toml
|
|
auto coinElem = ui::schema::UI().drawElement("components.main-window", "coin-icon");
|
|
auto cit = coinElem.extraColors.find("icon");
|
|
std::string coinFile = (cit != coinElem.extraColors.end() && !cit->second.empty())
|
|
? cit->second : "logos/logo_dragonx_128.png";
|
|
std::string coinPath = util::getExecutableDirectory() + "/res/img/" + coinFile;
|
|
std::error_code coinEc;
|
|
if (std::filesystem::exists(coinPath, coinEc) &&
|
|
util::LoadTextureFromFile(coinPath.c_str(), &coin_logo_tex_, &coin_logo_w_, &coin_logo_h_)) {
|
|
DEBUG_LOGF("Loaded coin logo from %s (%dx%d)\n", coinPath.c_str(), coin_logo_w_, coin_logo_h_);
|
|
} else {
|
|
// Try embedded resource fallback (Windows single-file distribution)
|
|
std::string coinBasename = std::filesystem::path(coinFile).filename().string();
|
|
const auto* coinRes = resources::getEmbeddedResource(coinBasename);
|
|
if (coinRes && coinRes->data && coinRes->size > 0) {
|
|
if (util::LoadTextureFromMemory(coinRes->data, coinRes->size, &coin_logo_tex_, &coin_logo_w_, &coin_logo_h_)) {
|
|
DEBUG_LOGF("Loaded coin logo from embedded: %s (%dx%d)\n", coinBasename.c_str(), coin_logo_w_, coin_logo_h_);
|
|
} else {
|
|
DEBUG_LOGF("Note: Failed to decode embedded coin logo\n");
|
|
}
|
|
} else {
|
|
DEBUG_LOGF("Note: Coin logo not found at %s\n", coinPath.c_str());
|
|
}
|
|
}
|
|
}
|
|
|
|
if (logo_tex_ != 0) {
|
|
sbStatus.logoTexID = logo_tex_;
|
|
sbStatus.logoW = logo_w_;
|
|
sbStatus.logoH = logo_h_;
|
|
}
|
|
if (gradient_tex_ != 0) {
|
|
sbStatus.gradientTexID = gradient_tex_;
|
|
}
|
|
// Count unconfirmed transactions (pending in mempool, not conflicted/orphaned).
|
|
// A transaction can appear as multiple legs sharing one txid (e.g. an autoshield send+receive),
|
|
// and a leg parsed from z_viewtransaction may carry confirmations=0 even when the transaction is
|
|
// long confirmed (the other leg holds the real count). So: a txid with ANY confirmed leg is
|
|
// confirmed, and we count UNIQUE unconfirmed txids — otherwise the badge sticks on stale 0-conf
|
|
// legs of already-confirmed transactions and double-counts multi-leg ones.
|
|
{
|
|
std::unordered_set<std::string> confirmedTxids;
|
|
for (const auto& tx : state_.transactions) {
|
|
if (tx.confirmations >= 1) confirmedTxids.insert(tx.txid);
|
|
}
|
|
std::unordered_set<std::string> unconfirmedTxids;
|
|
for (const auto& tx : state_.transactions) {
|
|
if (tx.confirmations == 0 && confirmedTxids.find(tx.txid) == confirmedTxids.end()) {
|
|
unconfirmedTxids.insert(tx.txid);
|
|
}
|
|
}
|
|
sbStatus.unconfirmedTxCount = static_cast<int>(unconfirmedTxids.size());
|
|
}
|
|
|
|
// Sidebar margins from ui.toml schema (DPI-scaled like all sidebar values)
|
|
const float sbMarginTop = sbde("margin-top", 0.0f);
|
|
const float sbMarginBottom = sbde("margin-bottom", 0.0f);
|
|
const float sbMinHeight = sbde("min-height", 360.0f);
|
|
|
|
// Ensure sidebar is tall enough to fit all buttons — shrink margins if needed
|
|
float sidebarH = contentH - sbMarginTop - sbMarginBottom;
|
|
float effectiveMarginTop = sbMarginTop;
|
|
if (sidebarH < sbMinHeight) {
|
|
float available = contentH - sbMinHeight;
|
|
if (available > 0.0f) {
|
|
float ratio = available / (sbMarginTop + sbMarginBottom);
|
|
effectiveMarginTop = sbMarginTop * ratio;
|
|
} else {
|
|
effectiveMarginTop = 0.0f;
|
|
}
|
|
sidebarH = std::max(contentH - effectiveMarginTop, sbMinHeight);
|
|
}
|
|
|
|
// Sidebar navigation
|
|
// Save cursor Y before applying sidebar margin so the content area
|
|
// (placed via SameLine) starts at the original row position, not the
|
|
// margin-shifted one.
|
|
float preSidebarCursorY = ImGui::GetCursorPosY();
|
|
if (effectiveMarginTop > 0.0f)
|
|
ImGui::SetCursorPosY(preSidebarCursorY + effectiveMarginTop);
|
|
bool prevCollapsed = sidebar_collapsed_;
|
|
{
|
|
PERF_SCOPE("Render.Sidebar");
|
|
ui::NavPage requestedPage = current_page_;
|
|
ui::RenderSidebar(requestedPage, sidebarW, sidebarH, sbStatus, sidebar_collapsed_,
|
|
state_.isLocked());
|
|
if (requestedPage != current_page_) {
|
|
setCurrentPage(requestedPage);
|
|
}
|
|
}
|
|
if (sbStatus.exitClicked) {
|
|
requestQuit();
|
|
}
|
|
if (sidebar_collapsed_ != prevCollapsed) {
|
|
sidebar_user_toggled_ = true; // user clicked chevron or pressed [
|
|
}
|
|
|
|
ImGui::SameLine();
|
|
// Restore cursor Y so content area is not shifted by sidebar margin
|
|
ImGui::SetCursorPosY(preSidebarCursorY);
|
|
|
|
// Page transition: detect change, ramp alpha
|
|
if (current_page_ != prev_page_) {
|
|
page_alpha_ = (ui::effects::isLowSpecMode() || (settings_ && settings_->getReduceMotion())) ? 1.0f : 0.0f;
|
|
prev_page_ = current_page_;
|
|
}
|
|
if (page_alpha_ < 1.0f) {
|
|
float dt = ImGui::GetIO().DeltaTime;
|
|
float pageFadeSpeed = ui::schema::UI().drawElement("components.main-window", "page-fade-speed").sizeOr(8.0f);
|
|
page_alpha_ += dt * pageFadeSpeed;
|
|
if (page_alpha_ > 1.0f) page_alpha_ = 1.0f;
|
|
}
|
|
|
|
// Content area — fills remaining width, tabs handle their own scrolling
|
|
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + caMarginTop);
|
|
ImGui::PushStyleVar(ImGuiStyleVar_Alpha,
|
|
ImGui::GetStyle().Alpha * page_alpha_);
|
|
ImGuiWindowFlags contentFlags = ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse;
|
|
if (ui::material::IsBackdropActive())
|
|
contentFlags |= ImGuiWindowFlags_NoBackground;
|
|
float caPadX = caWin.padding[0] > 0.0f ? caWin.padding[0] : ImGui::GetStyle().WindowPadding.x;
|
|
float caPadY = caWin.padding[1] > 0.0f ? caWin.padding[1] : ImGui::GetStyle().WindowPadding.y;
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(caPadX, caPadY));
|
|
|
|
// Capture content area screen position for edge fade mask
|
|
ImVec2 caScreenPos = ImGui::GetCursorScreenPos();
|
|
|
|
ImGui::BeginChild("##ContentArea", ImVec2(0, contentH), false, contentFlags);
|
|
|
|
// Capture vertex start for edge fade mask
|
|
ImDrawList* caDL = ImGui::GetWindowDrawList();
|
|
int caVtxStart = caDL->VtxBuffer.Size;
|
|
|
|
// Also capture ForegroundDrawList vertex start — DrawGlassPanel draws
|
|
// theme effects (rainbow border, edge trace, shimmer, specular glare)
|
|
// on the ForegroundDrawList, and those must also be edge-faded.
|
|
ImDrawList* fgDL = ImGui::GetForegroundDrawList();
|
|
int fgVtxStart = fgDL->VtxBuffer.Size;
|
|
|
|
// ---------------------------------------------------------------
|
|
// Loading overlay — show only on tabs that truly need wallet data.
|
|
// Tabs that work without full daemon connection:
|
|
// Console, Peers, Settings, Market, Mining
|
|
// Tabs that need balance/transaction data (show overlay):
|
|
// Overview, Send, Receive, History
|
|
// ---------------------------------------------------------------
|
|
bool pageNeedsWalletData = wallet::uiSurfaceNeedsWalletData(ui::NavPageSurface(current_page_));
|
|
bool daemonReady = state_.connected && !state_.warming_up;
|
|
|
|
// Don't show lock screen while pool mining — xmrig runs independently
|
|
// of the wallet and locking would block the mining UI needlessly.
|
|
bool poolMiningActive = xmrig_manager_ && xmrig_manager_->isRunning();
|
|
if (state_.isLocked() && !poolMiningActive) {
|
|
// Lock screen — covers tab content just like the loading overlay
|
|
renderLockScreen();
|
|
} else if (pageNeedsWalletData && state_.warming_up) {
|
|
// Daemon is reachable but still initializing — show warmup overlay
|
|
// only on wallet-data tabs. Other tabs render normally.
|
|
lock_screen_was_visible_ = false;
|
|
renderLoadingOverlay(contentH);
|
|
} else if (pageNeedsWalletData && (!daemonReady || (state_.connected && !state_.encryption_state_known))) {
|
|
// Track how long we've been waiting for encryption state
|
|
if (state_.connected && !state_.encryption_state_known) {
|
|
encryption_check_timer_ += ImGui::GetIO().DeltaTime;
|
|
if (encryption_check_timer_ >= 15.0f) {
|
|
DEBUG_LOGF("[App] Encryption state check timed out after 15s — assuming unencrypted\n");
|
|
state_.encryption_state_known = true;
|
|
state_.encrypted = false;
|
|
encryption_check_timer_ = 0.0f;
|
|
}
|
|
} else {
|
|
encryption_check_timer_ = 0.0f;
|
|
}
|
|
// Reset lock screen focus flag so it auto-focuses next time
|
|
lock_screen_was_visible_ = false;
|
|
// Show loading overlay instead of tab content
|
|
renderLoadingOverlay(contentH);
|
|
} else {
|
|
lock_screen_was_visible_ = false;
|
|
|
|
{
|
|
PERF_SCOPE("Render.ActiveTab");
|
|
switch (current_page_) {
|
|
case ui::NavPage::Overview:
|
|
ui::RenderBalanceTab(this);
|
|
break;
|
|
case ui::NavPage::Send:
|
|
ui::RenderSendTab(this);
|
|
break;
|
|
case ui::NavPage::Receive:
|
|
ui::RenderReceiveTab(this);
|
|
break;
|
|
case ui::NavPage::History:
|
|
ui::RenderTransactionsTab(this);
|
|
break;
|
|
case ui::NavPage::Contacts:
|
|
ui::RenderContactsTab(this);
|
|
break;
|
|
case ui::NavPage::Chat:
|
|
ui::RenderChatTab(this);
|
|
break;
|
|
case ui::NavPage::Mining:
|
|
ui::RenderMiningTab(this);
|
|
break;
|
|
case ui::NavPage::Peers:
|
|
ui::RenderPeersTab(this);
|
|
break;
|
|
case ui::NavPage::LiteNetwork:
|
|
ui::RenderLiteNetworkTab(this);
|
|
break;
|
|
case ui::NavPage::Explorer:
|
|
ui::RenderExplorerTab(this);
|
|
break;
|
|
case ui::NavPage::Market:
|
|
ui::RenderMarketTab(this);
|
|
break;
|
|
// One console UI for both variants; the executor supplies the backend (full-node
|
|
// RPC vs lite). The two NavPages are mutually exclusive per build.
|
|
case ui::NavPage::LiteConsole:
|
|
case ui::NavPage::Console:
|
|
if (!console_exec_) {
|
|
if (isLiteBuild())
|
|
console_exec_ = std::make_unique<ui::LiteConsoleExecutor>(this);
|
|
else
|
|
console_exec_ = std::make_unique<ui::FullNodeConsoleExecutor>(this);
|
|
}
|
|
console_tab_.render(*console_exec_);
|
|
// Persist the console toolbar toggles (ConsoleTab has no settings pointer). Save only when
|
|
// a value actually changed — this runs every frame the console is visible and save() is
|
|
// disk I/O — and only here in the Console case where these statics can change.
|
|
if (settings_) {
|
|
if (ui::ConsoleTab::s_line_accents_enabled != settings_->getConsoleLineAccents() ||
|
|
ui::ConsoleTab::s_line_text_color_enabled != settings_->getConsoleTextColor() ||
|
|
ui::ConsoleTab::s_console_zoom != settings_->getConsoleZoom()) {
|
|
settings_->setConsoleLineAccents(ui::ConsoleTab::s_line_accents_enabled);
|
|
settings_->setConsoleTextColor(ui::ConsoleTab::s_line_text_color_enabled);
|
|
settings_->setConsoleZoom(ui::ConsoleTab::s_console_zoom);
|
|
settings_->save();
|
|
}
|
|
}
|
|
break;
|
|
case ui::NavPage::Settings:
|
|
ui::RenderSettingsPage(this);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
} // PERF_SCOPE
|
|
|
|
} // end loading gate
|
|
|
|
// Snapshot ForegroundDrawList vertex count BEFORE viewport-wide effects
|
|
// so the edge fade only applies to per-panel theme effects, not embers/overlay.
|
|
int fgVtxEnd = fgDL->VtxBuffer.Size;
|
|
|
|
// Viewport-wide ambient theme effects (e.g. ember particles for fire themes)
|
|
{
|
|
PERF_SCOPE("Render.ThemeEffects");
|
|
auto& fx = ui::effects::ThemeEffects::instance();
|
|
ImDrawList* fgDl = ImGui::GetForegroundDrawList();
|
|
|
|
if (fx.hasEmberRise()) {
|
|
fx.drawViewportEmbers(fgDl);
|
|
}
|
|
|
|
// Sandstorm particles (wind-driven sand for desert themes)
|
|
if (fx.hasSandstorm()) {
|
|
fx.drawSandstorm(fgDl);
|
|
}
|
|
|
|
// Shader-like post-processing overlay (color wash + vignette)
|
|
if (fx.hasViewportOverlay()) {
|
|
fx.drawViewportOverlay(fgDl);
|
|
}
|
|
}
|
|
|
|
// Apply edge fade mask to content area — soft transparency at top/bottom edges
|
|
// so content doesn't appear sharply clipped at the content area boundary.
|
|
{
|
|
PERF_SCOPE("Render.EdgeFade");
|
|
float caTopEdge = caScreenPos.y;
|
|
float caBottomEdge = caScreenPos.y + contentH;
|
|
// Cache the schema lookup — value only changes on theme reload
|
|
static uint32_t s_fadeGen = 0;
|
|
static float s_fadeZone = 0.0f;
|
|
uint32_t curGen = ui::schema::UI().generation();
|
|
if (curGen != s_fadeGen) {
|
|
s_fadeGen = curGen;
|
|
s_fadeZone = ui::schema::UI().drawElement("components.content-area", "edge-fade-zone").size;
|
|
}
|
|
float fadeZone = s_fadeZone * ui::Layout::dpiScale();
|
|
// fadeZone <= 0 disables the effect entirely
|
|
if (fadeZone > 0.0f) {
|
|
// Pre-compute safe zone — vertices with y fully inside don't need any work
|
|
float safeTop = caTopEdge + fadeZone;
|
|
float safeBot = caBottomEdge - fadeZone;
|
|
|
|
// Lambda to fade a range of vertices in a given draw list
|
|
auto fadeVerts = [&](ImDrawList* dl, int vtxStart, int vtxEnd) {
|
|
for (int vi = vtxStart; vi < vtxEnd; vi++) {
|
|
ImDrawVert& v = dl->VtxBuffer[vi];
|
|
// Skip vertices in the safe zone (the common case)
|
|
if (v.pos.y >= safeTop && v.pos.y <= safeBot)
|
|
continue;
|
|
|
|
float alpha = 1.0f;
|
|
|
|
// Top fade
|
|
float dTop = v.pos.y - caTopEdge;
|
|
if (dTop < fadeZone)
|
|
alpha = std::min(alpha, std::max(0.0f, dTop / fadeZone));
|
|
|
|
// Bottom fade
|
|
float dBot = caBottomEdge - v.pos.y;
|
|
if (dBot < fadeZone)
|
|
alpha = std::min(alpha, std::max(0.0f, dBot / fadeZone));
|
|
|
|
if (alpha < 1.0f) {
|
|
int a = (v.col >> IM_COL32_A_SHIFT) & 0xFF;
|
|
a = static_cast<int>(a * alpha);
|
|
v.col = (v.col & ~IM_COL32_A_MASK) | (static_cast<ImU32>(a) << IM_COL32_A_SHIFT);
|
|
}
|
|
}
|
|
};
|
|
// Apply to foreground panel effects (rainbow borders, edge trace,
|
|
// shimmer, specular glare) — but NOT viewport-wide effects (embers,
|
|
// overlay) which were added after fgVtxEnd.
|
|
fadeVerts(fgDL, fgVtxStart, fgVtxEnd);
|
|
}
|
|
}
|
|
|
|
// Page-transition alpha: during page switches page_alpha_ ramps 0→1.
|
|
// ImGuiStyleVar_Alpha only affects widget rendering inside the content
|
|
// child window. ForegroundDrawList effects (per-panel AND viewport-wide)
|
|
// bypass it, so we manually scale their vertex alpha here.
|
|
if (page_alpha_ < 1.0f) {
|
|
int fgEnd = fgDL->VtxBuffer.Size;
|
|
for (int vi = fgVtxStart; vi < fgEnd; vi++) {
|
|
ImDrawVert& v = fgDL->VtxBuffer[vi];
|
|
int a = (v.col >> IM_COL32_A_SHIFT) & 0xFF;
|
|
a = static_cast<int>(a * page_alpha_);
|
|
v.col = (v.col & ~IM_COL32_A_MASK) | (static_cast<ImU32>(a) << IM_COL32_A_SHIFT);
|
|
}
|
|
}
|
|
|
|
ImGui::EndChild();
|
|
ImGui::PopStyleVar(); // WindowPadding (content area)
|
|
ImGui::PopStyleVar(); // Alpha
|
|
|
|
// Status bar — anchored to the bottom of the main window
|
|
// Use mainPadX/mainPadBot (the actual MainContent window padding) because the
|
|
// style var was already popped and GetStyle().WindowPadding is now wrong.
|
|
{
|
|
PERF_SCOPE("Render.StatusBar");
|
|
float windowBottom = ImGui::GetWindowPos().y + ImGui::GetWindowSize().y;
|
|
float statusY = windowBottom - statusBarH - mainPadBot;
|
|
ImGui::SetCursorScreenPos(ImVec2(ImGui::GetWindowPos().x + mainPadX, statusY));
|
|
renderStatusBar();
|
|
}
|
|
|
|
// ====================================================================
|
|
// Modal dialogs — rendered INSIDE "MainContent" window scope so that
|
|
// ImGui's modal dim-layer and input blocking covers the sidebar,
|
|
// content area, status bar, and every other widget in this window.
|
|
// ====================================================================
|
|
PERF_BEGIN(_perfDialogs);
|
|
if (show_settings_) {
|
|
ui::RenderSettingsWindow(this, &show_settings_);
|
|
}
|
|
|
|
if (show_about_) {
|
|
ui::RenderAboutDialog(this, &show_about_);
|
|
}
|
|
|
|
// Lite first-run welcome: prompt to create/restore when no wallet file exists yet.
|
|
renderLiteFirstRunPrompt();
|
|
// Lite send-time unlock prompt (shown when a spend is attempted on a locked wallet).
|
|
renderLiteUnlockPrompt();
|
|
|
|
if (show_import_key_) {
|
|
renderImportKeyDialog();
|
|
}
|
|
|
|
if (show_export_key_) {
|
|
renderExportKeyDialog();
|
|
}
|
|
|
|
if (show_backup_) {
|
|
renderBackupDialog();
|
|
}
|
|
|
|
if (show_seed_backup_) {
|
|
renderSeedBackupDialog();
|
|
}
|
|
|
|
if (show_seed_migration_) {
|
|
renderSeedMigrationDialog();
|
|
}
|
|
|
|
if (show_daemon_update_prompt_ && !capture_mode_) {
|
|
renderDaemonUpdatePrompt();
|
|
}
|
|
|
|
// Security overlay dialogs (encrypt, decrypt, PIN) are rendered AFTER ImGui::End()
|
|
// to ensure they appear on top of all other content
|
|
|
|
// Send confirm popup
|
|
ui::RenderSendConfirmPopup(this);
|
|
|
|
// Console RPC Command Reference popup
|
|
console_tab_.renderCommandsPopupModal();
|
|
|
|
// Key export dialog (triggered from balance tab context menu)
|
|
ui::KeyExportDialog::render(this);
|
|
|
|
// Transaction details dialog (triggered from transactions tab)
|
|
ui::TransactionDetailsDialog::render(this);
|
|
|
|
// QR code popup dialog (triggered from balance tab)
|
|
ui::QRPopupDialog::render(this);
|
|
|
|
// Validate address dialog (triggered from Edit menu)
|
|
ui::ValidateAddressDialog::render(this);
|
|
|
|
// Shield/merge dialog (triggered from Wallet menu)
|
|
ui::ShieldDialog::render(this);
|
|
|
|
// Request payment dialog (triggered from Wallet menu)
|
|
ui::RequestPaymentDialog::render(this);
|
|
|
|
// Block info dialog (triggered from View menu)
|
|
ui::BlockInfoDialog::render(this);
|
|
|
|
// Export all keys dialog (triggered from File menu)
|
|
ui::ExportAllKeysDialog::render(this);
|
|
|
|
// Export transactions to CSV dialog (triggered from File menu)
|
|
ui::ExportTransactionsDialog::render(this);
|
|
|
|
// Address label/icon editor
|
|
ui::AddressLabelDialog::render();
|
|
|
|
// Address-to-address transfer confirmation
|
|
ui::AddressTransferDialog::render();
|
|
|
|
// Bootstrap download from settings
|
|
ui::BootstrapDownloadDialog::render();
|
|
ui::XmrigDownloadDialog::render();
|
|
ui::DaemonUpdateDialog::render();
|
|
ui::WalletsDialog::render();
|
|
|
|
// Windows Defender antivirus help dialog
|
|
renderAntivirusHelpDialog();
|
|
|
|
PERF_END("Render.Dialogs", _perfDialogs);
|
|
|
|
ImGui::End();
|
|
|
|
// Debug: ImGui demo window
|
|
if (show_demo_window_) {
|
|
ImGui::ShowDemoWindow(&show_demo_window_);
|
|
}
|
|
|
|
// Security overlay dialogs (must render LAST to be on top of everything)
|
|
renderEncryptWalletDialog();
|
|
renderDecryptWalletDialog();
|
|
renderPinDialogs();
|
|
|
|
// Render notifications (toast messages)
|
|
ui::Notifications::instance().render();
|
|
|
|
// Latch whether any blur overlay was drawn this frame (for next frame's ModalRenderGuard) and, on
|
|
// the overlay's close transition, invalidate the acrylic capture so glass panels re-blur cleanly.
|
|
ui::material::LatchBlurOverlayActive();
|
|
}
|
|
|
|
void App::renderStatusBar()
|
|
{
|
|
// Status bar layout from unified UI schema
|
|
const auto& S = ui::schema::UI();
|
|
const auto& sbWin = S.window("components.status-bar");
|
|
const float sbHeight = sbWin.height;
|
|
const float sbPadX = sbWin.padding[0];
|
|
const float sbPadY = sbWin.padding[1];
|
|
const float sbIconTextGap = S.drawElement("components.status-bar", "icon-text-gap").size;
|
|
const float sbSectionGap = S.drawElement("components.status-bar", "section-gap").size;
|
|
const float sbSeparatorGap = S.drawElement("components.status-bar", "separator-gap").size;
|
|
|
|
ImGuiWindowFlags childFlags = ImGuiWindowFlags_NoScrollbar |
|
|
ImGuiWindowFlags_NoScrollWithMouse;
|
|
if (ui::material::IsBackdropActive()) {
|
|
childFlags |= ImGuiWindowFlags_NoBackground;
|
|
}
|
|
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(sbPadX, sbPadY));
|
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0));
|
|
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0, 0, 0, 0));
|
|
ImGui::BeginChild("##StatusBar", ImVec2(0, sbHeight), false, childFlags);
|
|
|
|
// Use schema-configured font for status bar text (default: body2 = caption+1px)
|
|
auto sbTextStyle = S.label("components.status-bar", "text-style");
|
|
ImFont* sbFont = S.resolveFont(sbTextStyle.font);
|
|
if (!sbFont) sbFont = ui::material::Type().body2();
|
|
ImGui::PushFont(sbFont);
|
|
|
|
// Apply text opacity from schema (default 60%)
|
|
auto sbTextElem = S.drawElement("components.status-bar", "text-style");
|
|
float sbTextOpacity = (sbTextElem.opacity >= 0.0f) ? sbTextElem.opacity : 0.6f;
|
|
ImVec4 baseTextCol = ImGui::GetStyleColorVec4(ImGuiCol_Text);
|
|
baseTextCol.w *= sbTextOpacity;
|
|
ImGui::PushStyleColor(ImGuiCol_Text, baseTextCol);
|
|
|
|
// Vertically center text within the status bar below the divider line.
|
|
// sbPadY is already applied by WindowPadding, so the content region starts
|
|
// at wPos.y + sbPadY. We set CursorPosY relative to wPos.y (not content
|
|
// start), so we must account for sbPadY ourselves to avoid placing text
|
|
// above the padded region.
|
|
{
|
|
float fontH = ImGui::GetFont()->LegacySize;
|
|
float topMargin = sbPadY; // match the window padding = space below divider
|
|
float availH = sbHeight - topMargin;
|
|
float centerY = topMargin + (availH - fontH) * 0.5f;
|
|
if (centerY > 0.0f)
|
|
ImGui::SetCursorPosY(centerY);
|
|
}
|
|
{
|
|
ImVec2 wPos = ImGui::GetWindowPos();
|
|
float wWidth = ImGui::GetWindowWidth();
|
|
ImGui::GetWindowDrawList()->AddLine(
|
|
ImVec2(wPos.x, wPos.y),
|
|
ImVec2(wPos.x + wWidth, wPos.y),
|
|
ui::schema::UI().resolveColor("var(--status-divider)", IM_COL32(255, 255, 255, 20)), 1.0f);
|
|
}
|
|
|
|
// Connection status
|
|
float dotOpacity = S.drawElement("components.status-bar", "connection-dot").opacity;
|
|
if (dotOpacity < 0.0f) dotOpacity = 1.0f;
|
|
if (state_.warming_up || state_.daemon_initializing) {
|
|
// Both states mean "daemon reachable/launching but not serving yet" — show the same amber
|
|
// status so tabs without the overlay (Peers, Console) still tell the user what's happening.
|
|
ImGui::PushFont(ui::material::Type().iconSmall());
|
|
ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.0f, dotOpacity), ICON_MD_CIRCLE);
|
|
ImGui::PopFont();
|
|
ImGui::SameLine(0, sbIconTextGap);
|
|
// Show truncated warmup status (e.g. "Activating best chain... (Block 12345)")
|
|
const char* warmupText = state_.warmup_status.empty()
|
|
? TR("sb_warming_up") : state_.warmup_status.c_str();
|
|
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", warmupText);
|
|
} else if (state_.connected) {
|
|
ImGui::PushFont(ui::material::Type().iconSmall());
|
|
ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, dotOpacity), ICON_MD_CIRCLE);
|
|
ImGui::PopFont();
|
|
ImGui::SameLine(0, sbIconTextGap);
|
|
ImGui::Text("%s", TR("connected"));
|
|
} else {
|
|
ImGui::PushFont(ui::material::Type().iconSmall());
|
|
ImGui::TextColored(ImVec4(0.8f, 0.2f, 0.2f, dotOpacity), ICON_MD_CIRCLE);
|
|
ImGui::PopFont();
|
|
ImGui::SameLine(0, sbIconTextGap);
|
|
// Lite has no daemon connection; "disconnected" here means no wallet is open.
|
|
ImGui::Text("%s", TR(isLiteBuild() ? "lite_no_wallet_short" : "disconnected"));
|
|
}
|
|
|
|
// Block height
|
|
ImGui::SameLine(0, sbSectionGap);
|
|
ImGui::TextDisabled("|");
|
|
ImGui::SameLine(0, sbSeparatorGap);
|
|
ImGui::Text(TR("sb_block"), state_.sync.blocks);
|
|
|
|
// Sync status or peer count
|
|
ImGui::SameLine(0, sbSectionGap);
|
|
ImGui::TextDisabled("|");
|
|
ImGui::SameLine(0, sbSeparatorGap);
|
|
if (state_.sync.building_witnesses) {
|
|
// Witness rebuild is the tail phase of a rescan — show its own progress with priority.
|
|
// Phase 1 (initial pass) and phase 2 (cache walk) have distinct labels so the progress
|
|
// restart at the phase boundary reads as a new step rather than a glitch.
|
|
const ImVec4 witCol(0.6f, 0.8f, 1.0f, 1.0f);
|
|
const char* witLabel = (state_.sync.witness_phase == 2)
|
|
? TR("sb_witness_cache") : TR("sb_building_witnesses");
|
|
if (state_.sync.witness_progress > 0.01f) {
|
|
ImGui::TextColored(witCol, "%s %.0f%%", witLabel, state_.sync.witness_progress * 100.0f);
|
|
} else {
|
|
int dots = (int)(ImGui::GetTime() * 2.0f) % 4;
|
|
const char* dotStr = (dots == 0) ? "." : (dots == 1) ? ".." : (dots == 2) ? "..." : "";
|
|
ImGui::TextColored(witCol, "%s%s", witLabel, dotStr);
|
|
}
|
|
} else if (state_.sync.rescanning) {
|
|
// Show rescan progress (takes priority over sync)
|
|
// Use animated dots if progress is unknown (0%)
|
|
if (state_.sync.rescan_progress > 0.01f) {
|
|
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), TR("sb_rescanning_pct"),
|
|
state_.sync.rescan_progress * 100.0f);
|
|
} else {
|
|
// Animated "Rescanning..." with pulsing dots
|
|
int dots = (int)(ImGui::GetTime() * 2.0f) % 4;
|
|
const char* dotStr = (dots == 0) ? "." : (dots == 1) ? ".." : (dots == 2) ? "..." : "";
|
|
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", TR("sb_rescanning"), dotStr);
|
|
}
|
|
} else if (state_.sync.syncing) {
|
|
int chainTip = state_.longestchain > 0 ? state_.longestchain : state_.sync.headers;
|
|
int blocksLeft = chainTip - state_.sync.blocks;
|
|
if (blocksLeft < 0) blocksLeft = 0;
|
|
|
|
// Calculate sync speed (blocks/sec) using a smoothed rolling average
|
|
static int s_prev_blocks = 0;
|
|
static double s_prev_time = 0.0;
|
|
static double s_blocks_per_sec = 0.0;
|
|
double now = ImGui::GetTime();
|
|
if (s_prev_time == 0.0) {
|
|
// First sample — seed baseline, don't compute rate yet
|
|
s_prev_blocks = state_.sync.blocks;
|
|
s_prev_time = now;
|
|
} else if (state_.sync.blocks > s_prev_blocks) {
|
|
double dt = now - s_prev_time;
|
|
if (dt > 0.5) {
|
|
double raw = (state_.sync.blocks - s_prev_blocks) / dt;
|
|
// Exponential smoothing (alpha ~0.3 per update)
|
|
s_blocks_per_sec = s_blocks_per_sec > 0.0
|
|
? s_blocks_per_sec * 0.7 + raw * 0.3
|
|
: raw;
|
|
s_prev_blocks = state_.sync.blocks;
|
|
s_prev_time = now;
|
|
}
|
|
} else if (now - s_prev_time > 10.0) {
|
|
// No new blocks for 10s — decay the displayed rate
|
|
s_blocks_per_sec *= 0.5;
|
|
s_prev_time = now;
|
|
}
|
|
|
|
if (s_blocks_per_sec > 0.1) {
|
|
int eta_sec = (int)(blocksLeft / s_blocks_per_sec);
|
|
char eta[32];
|
|
if (eta_sec >= 3600)
|
|
snprintf(eta, sizeof(eta), "%dh %dm", eta_sec / 3600, (eta_sec % 3600) / 60);
|
|
else if (eta_sec >= 60)
|
|
snprintf(eta, sizeof(eta), "%dm %ds", eta_sec / 60, eta_sec % 60);
|
|
else
|
|
snprintf(eta, sizeof(eta), "%ds", eta_sec);
|
|
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), TR("sb_syncing_eta"),
|
|
state_.sync.verification_progress * 100.0, blocksLeft, s_blocks_per_sec, eta);
|
|
} else {
|
|
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), TR("sb_syncing_basic"),
|
|
state_.sync.verification_progress * 100.0, blocksLeft);
|
|
}
|
|
} else if (state_.connected && !isLiteBuild()) {
|
|
// Lite has no P2P peers (the lite server isn't a peer set); skip the peer count.
|
|
ImGui::Text(TR("sb_peers"), state_.peers.size());
|
|
}
|
|
|
|
// Network hashrate (if connected and have data)
|
|
if (state_.connected && state_.mining.networkHashrate > 0) {
|
|
ImGui::SameLine(0, sbSectionGap);
|
|
ImGui::TextDisabled("|");
|
|
ImGui::SameLine(0, sbSeparatorGap);
|
|
if (state_.mining.networkHashrate >= 1e9) {
|
|
ImGui::Text(TR("sb_net_ghs"), state_.mining.networkHashrate / 1e9);
|
|
} else if (state_.mining.networkHashrate >= 1e6) {
|
|
ImGui::Text(TR("sb_net_mhs"), state_.mining.networkHashrate / 1e6);
|
|
} else if (state_.mining.networkHashrate >= 1e3) {
|
|
ImGui::Text(TR("sb_net_khs"), state_.mining.networkHashrate / 1e3);
|
|
} else {
|
|
ImGui::Text(TR("sb_net_hs"), state_.mining.networkHashrate);
|
|
}
|
|
}
|
|
|
|
// Mining indicator (if mining — solo or pool)
|
|
const bool anyMining = state_.mining.generate || state_.pool_mining.xmrig_running;
|
|
if (anyMining) {
|
|
ImGui::SameLine(0, sbSectionGap);
|
|
ImGui::TextDisabled("|");
|
|
ImGui::SameLine(0, sbSeparatorGap);
|
|
ImGui::PushFont(ui::material::Type().iconSmall());
|
|
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), ICON_MD_CONSTRUCTION);
|
|
ImGui::PopFont();
|
|
ImGui::SameLine(0, sbIconTextGap);
|
|
double displayHashrate = state_.pool_mining.xmrig_running
|
|
? state_.pool_mining.hashrate_10s
|
|
: state_.mining.localHashrate;
|
|
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), TR("sb_mining_hs"),
|
|
displayHashrate);
|
|
}
|
|
|
|
// Transaction submission/operation progress
|
|
if (hasTransactionSendProgress()) {
|
|
ImGui::SameLine(0, sbSectionGap);
|
|
ImGui::TextDisabled("|");
|
|
ImGui::SameLine(0, sbSeparatorGap);
|
|
ImGui::PushFont(ui::material::Type().iconSmall());
|
|
float pulse = 0.6f + 0.4f * sinf((float)ImGui::GetTime() * 3.0f);
|
|
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, pulse), ICON_MD_SEND);
|
|
ImGui::PopFont();
|
|
ImGui::SameLine(0, sbIconTextGap);
|
|
int dots = (int)(ImGui::GetTime() * 2.0f) % 4;
|
|
const char* dotStr = (dots == 0) ? "." : (dots == 1) ? ".." : (dots == 2) ? "..." : "";
|
|
std::string status = transactionSendProgressText();
|
|
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", status.c_str(), dotStr);
|
|
}
|
|
|
|
// Decrypt-import background task indicator
|
|
if (wallet_security_workflow_.importActive()) {
|
|
ImGui::SameLine(0, sbSectionGap);
|
|
ImGui::TextDisabled("|");
|
|
ImGui::SameLine(0, sbSeparatorGap);
|
|
ImGui::PushFont(ui::material::Type().iconSmall());
|
|
float pulse = 0.6f + 0.4f * sinf((float)ImGui::GetTime() * 3.0f);
|
|
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, pulse), ICON_MD_LOCK_OPEN);
|
|
ImGui::PopFont();
|
|
ImGui::SameLine(0, sbIconTextGap);
|
|
int dots = (int)(ImGui::GetTime() * 2.0f) % 4;
|
|
const char* dotStr = (dots == 0) ? "." : (dots == 1) ? ".." : (dots == 2) ? "..." : "";
|
|
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", TR("sb_importing_keys"), dotStr);
|
|
}
|
|
|
|
// Right side: version always at far right, connection status to its left.
|
|
// Compute positions dynamically from actual text widths so they
|
|
// never overlap and always stay within the window at any font scale.
|
|
{
|
|
char versionBuf[32];
|
|
snprintf(versionBuf, sizeof(versionBuf), "v%s", DRAGONX_VERSION);
|
|
float versionW = ImGui::CalcTextSize(versionBuf).x;
|
|
float rightPad = sbPadX; // match the left window padding
|
|
float versionX = ImGui::GetWindowWidth() - versionW - rightPad;
|
|
|
|
// Connection / daemon status sits to the left of the version string
|
|
// with a small gap.
|
|
float gap = sbSectionGap;
|
|
if (!connection_status_.empty() && connection_status_ != "Connected") {
|
|
float statusW = ImGui::CalcTextSize(connection_status_.c_str()).x;
|
|
float statusX = versionX - statusW - gap;
|
|
ImGui::SameLine(statusX);
|
|
ImGui::TextDisabled("%s", connection_status_.c_str());
|
|
} else if (!daemon_status_.empty() && daemon_status_.find("Error") != std::string::npos) {
|
|
const char* errText = TR("sb_daemon_not_found");
|
|
float statusW = ImGui::CalcTextSize(errText).x;
|
|
float statusX = versionX - statusW - gap;
|
|
ImGui::SameLine(statusX);
|
|
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "%s", errText);
|
|
}
|
|
|
|
// Version always at far right
|
|
ImGui::SameLine(versionX);
|
|
ImGui::Text("%s", versionBuf);
|
|
}
|
|
|
|
ImGui::PopStyleColor(1); // Text opacity
|
|
ImGui::PopFont(); // status bar font
|
|
ImGui::EndChild();
|
|
ImGui::PopStyleColor(2); // ChildBg + Border
|
|
ImGui::PopStyleVar(1); // WindowPadding
|
|
}
|
|
|
|
void App::reloadThemeImages(const std::string& bgPath, const std::string& logoPath)
|
|
{
|
|
// Reload background image from the path resolved by the skin system.
|
|
// Each theme specifies its image via [theme] images.background_image in its .toml.
|
|
if (!bgPath.empty()) {
|
|
ImTextureID newTex = 0;
|
|
int w = 0, h = 0;
|
|
bool loaded = false;
|
|
#ifdef _WIN32
|
|
// On Windows, try embedded resource by filename if file load fails
|
|
{
|
|
std::filesystem::path p(bgPath);
|
|
std::string filename = p.filename().string();
|
|
const auto* res = resources::getEmbeddedResource(filename);
|
|
if (res && res->data && res->size > 0) {
|
|
loaded = util::LoadTextureFromMemory(res->data, res->size, &newTex, &w, &h);
|
|
if (loaded)
|
|
DEBUG_LOGF("[App] Loaded theme background from embedded: %s (%dx%d)\n", filename.c_str(), w, h);
|
|
}
|
|
}
|
|
#endif
|
|
if (!loaded) {
|
|
loaded = util::LoadTextureFromFile(bgPath.c_str(), &newTex, &w, &h);
|
|
if (loaded)
|
|
DEBUG_LOGF("[App] Loaded theme background image: %s (%dx%d)\n", bgPath.c_str(), w, h);
|
|
else
|
|
DEBUG_LOGF("[App] Warning: Failed to load theme background: %s\n", bgPath.c_str());
|
|
}
|
|
if (loaded)
|
|
gradient_tex_ = newTex;
|
|
else
|
|
gradient_tex_ = 0; // Clear stale texture when load fails
|
|
} else {
|
|
// No background image specified by theme — clear texture,
|
|
// main loop will fall back to programmatic gradient
|
|
gradient_tex_ = 0;
|
|
}
|
|
|
|
// Reset logo loaded flags — will reload on next render frame
|
|
logo_loaded_ = false;
|
|
logo_tex_ = 0;
|
|
logo_w_ = 0;
|
|
logo_h_ = 0;
|
|
coin_logo_loaded_ = false;
|
|
coin_logo_tex_ = 0;
|
|
coin_logo_w_ = 0;
|
|
coin_logo_h_ = 0;
|
|
}
|
|
|
|
void App::renderLiteFirstRunPrompt()
|
|
{
|
|
// Lite-only guided onboarding (full-node uses its own wizard). Welcome -> (on create) reveal
|
|
// the new seed + birthday with backup warnings -> verify the user wrote it down -> done.
|
|
if (!lite_wallet_ || lite_firstrun_dismissed_) return;
|
|
|
|
// Wizard state (persists across frames). The seed is SECRET — wiped in finish().
|
|
static int step = 0; // 0 welcome, 1 reveal seed, 2 verify
|
|
static std::string seed; // full seed phrase
|
|
static unsigned long long birthday = 0;
|
|
static std::vector<std::string> words; // correct order
|
|
static std::vector<std::pair<std::string, bool>> chips; // shuffled (word, consumed)
|
|
static int progress = 0; // # words confirmed in order
|
|
static double wrongFlashUntil = 0.0; // brief "not the next word" hint
|
|
static bool skipConfirm = false; // "Skip" backup requires a second confirming click
|
|
static bool creating = false; // async create (with failover) in flight
|
|
// Restore-from-seed step (step 3). The seed buffer is SECRET — wiped in finish() and on Back.
|
|
static char restoreSeed[512] = {};
|
|
static int restoreBirthday = 0;
|
|
static bool restoring = false; // async restore in flight
|
|
static std::string restoreErr;
|
|
|
|
// The welcome page is only relevant before any wallet exists; once create has started
|
|
// (creating) or reached reveal/verify (step>0), keep showing it through to completion even
|
|
// though the wallet becomes open.
|
|
if (step == 0 && !creating && (lite_wallet_->walletOpen() || lite_wallet_->walletExists())) return;
|
|
|
|
if (!ImGui::IsPopupOpen("##LiteFirstRun")) ImGui::OpenPopup("##LiteFirstRun");
|
|
ImGuiViewport* vp = ImGui::GetMainViewport();
|
|
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f),
|
|
ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
|
|
|
auto finish = [&]() {
|
|
wallet::secureWipeLiteSecret(seed);
|
|
sodium_memzero(restoreSeed, sizeof(restoreSeed));
|
|
restoreBirthday = 0;
|
|
restoring = false;
|
|
restoreErr.clear();
|
|
words.clear();
|
|
chips.clear();
|
|
progress = 0;
|
|
step = 0;
|
|
skipConfirm = false;
|
|
creating = false;
|
|
lite_firstrun_dismissed_ = true;
|
|
ImGui::CloseCurrentPopup();
|
|
};
|
|
auto splitWords = [](const std::string& s) {
|
|
std::vector<std::string> out;
|
|
std::string w;
|
|
for (char c : s) {
|
|
if (std::isspace(static_cast<unsigned char>(c))) { if (!w.empty()) { out.push_back(w); w.clear(); } }
|
|
else w.push_back(c);
|
|
}
|
|
if (!w.empty()) out.push_back(w);
|
|
return out;
|
|
};
|
|
|
|
if (ImGui::BeginPopupModal("##LiteFirstRun", nullptr,
|
|
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
|
|
const float btnW = 170.0f;
|
|
|
|
if (step == 0) {
|
|
// ── Welcome ──────────────────────────────────────────────────────────
|
|
ImGui::PushFont(ui::material::Type().subtitle1());
|
|
ImGui::TextUnformatted(TR("lite_welcome_title"));
|
|
ImGui::PopFont();
|
|
ImGui::Spacing();
|
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 360.0f);
|
|
ImGui::TextUnformatted(TR("lite_welcome_msg"));
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::Spacing(); ImGui::Spacing();
|
|
|
|
if (creating) {
|
|
// Async create (with server failover) is in flight — driven to completion by
|
|
// App::update()'s pumpAsyncOpen(). Poll the controller for the outcome.
|
|
ImGui::TextUnformatted("Creating your wallet\xE2\x80\xA6");
|
|
if (lite_wallet_->walletOpen()) {
|
|
auto s = lite_wallet_->exportSeed(); // read the new seed back (local, fast)
|
|
if (s.ok && !s.seedPhrase.empty()) {
|
|
seed = s.seedPhrase;
|
|
birthday = s.birthday;
|
|
wallet::secureWipeLiteSecret(s.seedPhrase);
|
|
words = splitWords(seed);
|
|
creating = false;
|
|
step = 1;
|
|
} else {
|
|
ui::Notifications::instance().success(TR("lite_welcome_created"), 8.0f);
|
|
setCurrentPage(ui::NavPage::Settings);
|
|
finish();
|
|
}
|
|
} else if (!lite_wallet_->openInProgress() &&
|
|
!lite_wallet_->lastOpenError().empty()) {
|
|
ui::Notifications::instance().warning(
|
|
std::string("Create failed: ") + lite_wallet_->lastOpenError());
|
|
creating = false; // back to the buttons so the user can retry
|
|
}
|
|
} else {
|
|
if (ui::material::TactileButton(TR("lite_welcome_create"), ImVec2(btnW, 0))) {
|
|
// Async create with the same server failover as open (no UI freeze; a dead
|
|
// server falls through to the next). On success the wizard reveals the seed.
|
|
if (lite_wallet_->beginCreateWallet()) {
|
|
creating = true;
|
|
} else {
|
|
ui::Notifications::instance().warning(TR("lite_welcome_create_failed"));
|
|
}
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(TR("lite_welcome_restore"), ImVec2(btnW, 0))) {
|
|
step = 3; // inline seed-entry restore (see step 3 below)
|
|
}
|
|
ImGui::Spacing();
|
|
if (ui::material::TactileButton(TR("lite_welcome_later"),
|
|
ImVec2(btnW * 2 + ImGui::GetStyle().ItemSpacing.x, 0))) {
|
|
finish();
|
|
}
|
|
}
|
|
} else if (step == 1) {
|
|
// ── Reveal the seed + birthday with backup warnings ─────────────────────
|
|
ImGui::PushFont(ui::material::Type().subtitle1());
|
|
ImGui::TextUnformatted("Back up your seed phrase");
|
|
ImGui::PopFont();
|
|
ImGui::Spacing();
|
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
|
ImGui::TextUnformatted("These 24 words are the ONLY way to restore your wallet. "
|
|
"Write them down in order, store them offline, and never share "
|
|
"them. If you lose them, your funds are gone forever.");
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::Spacing();
|
|
|
|
// Numbered word grid (4 columns).
|
|
for (size_t i = 0; i < words.size(); ++i) {
|
|
char cell[96];
|
|
snprintf(cell, sizeof(cell), "%2zu. %s", i + 1, words[i].c_str());
|
|
ImGui::TextUnformatted(cell);
|
|
if ((i % 4) != 3 && i + 1 < words.size()) ImGui::SameLine(((i % 4) + 1) * 130.0f);
|
|
}
|
|
ImGui::Spacing();
|
|
char bday[80];
|
|
snprintf(bday, sizeof(bday), "Birthday (block height): %llu — back this up too.", birthday);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
|
|
ImGui::TextUnformatted(bday);
|
|
ImGui::PopStyleColor();
|
|
ImGui::Spacing(); ImGui::Spacing();
|
|
|
|
if (ui::material::TactileButton("I've written it down", ImVec2(btnW, 0))) {
|
|
chips.clear();
|
|
for (const auto& w : words) chips.emplace_back(w, false);
|
|
std::mt19937 rng{std::random_device{}()};
|
|
std::shuffle(chips.begin(), chips.end(), rng);
|
|
progress = 0;
|
|
skipConfirm = false;
|
|
step = 2;
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton("Copy", ImVec2(80, 0))) copySecretToClipboard(seed);
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(skipConfirm ? "Skip anyway" : "Skip", ImVec2(120, 0))) {
|
|
if (!skipConfirm) {
|
|
skipConfirm = true; // require a second, deliberate click
|
|
} else {
|
|
ui::Notifications::instance().success(TR("lite_welcome_created"), 6.0f);
|
|
finish();
|
|
}
|
|
}
|
|
if (skipConfirm) {
|
|
ImGui::Spacing();
|
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
|
ImGui::TextUnformatted("You have not backed up your seed — funds could be lost. "
|
|
"Skip anyway?");
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopTextWrapPos();
|
|
}
|
|
} else if (step == 2) {
|
|
// ── Verify: tap the words in order ──────────────────────────────────────
|
|
ImGui::PushFont(ui::material::Type().subtitle1());
|
|
ImGui::TextUnformatted("Confirm your backup");
|
|
ImGui::PopFont();
|
|
ImGui::Spacing();
|
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
|
|
ImGui::TextUnformatted("Tap the words in the correct order to confirm you saved them.");
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::Spacing();
|
|
|
|
ImGui::TextDisabled("Progress: %d / %d", progress, (int)words.size());
|
|
if (ImGui::GetTime() < wrongFlashUntil) {
|
|
ImGui::SameLine();
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
|
ImGui::TextUnformatted(" — that's not the next word");
|
|
ImGui::PopStyleColor();
|
|
}
|
|
ImGui::Spacing();
|
|
|
|
for (size_t i = 0; i < chips.size(); ++i) {
|
|
ImGui::PushID((int)i);
|
|
if (chips[i].second) {
|
|
ImGui::BeginDisabled();
|
|
ui::material::TactileButton(chips[i].first.c_str(), ImVec2(125, 0));
|
|
ImGui::EndDisabled();
|
|
} else if (ui::material::TactileButton(chips[i].first.c_str(), ImVec2(125, 0))) {
|
|
if (progress < (int)words.size() && chips[i].first == words[progress]) {
|
|
chips[i].second = true; // correct next word
|
|
++progress;
|
|
} else {
|
|
wrongFlashUntil = ImGui::GetTime() + 1.5; // out of order
|
|
}
|
|
}
|
|
ImGui::PopID();
|
|
if ((i % 4) != 3 && i + 1 < chips.size()) ImGui::SameLine();
|
|
}
|
|
ImGui::Spacing(); ImGui::Spacing();
|
|
|
|
const bool verified = progress == (int)words.size();
|
|
if (!verified) ImGui::BeginDisabled();
|
|
if (ui::material::TactileButton("Done", ImVec2(btnW, 0))) {
|
|
ui::Notifications::instance().success("Wallet created and backed up.", 6.0f);
|
|
finish();
|
|
}
|
|
if (!verified) ImGui::EndDisabled();
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton("Back", ImVec2(80, 0))) { skipConfirm = false; step = 1; }
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(skipConfirm ? "Skip anyway" : "Skip", ImVec2(120, 0))) {
|
|
if (!skipConfirm) {
|
|
skipConfirm = true; // require a second, deliberate click
|
|
} else {
|
|
ui::Notifications::instance().success(TR("lite_welcome_created"), 6.0f);
|
|
finish();
|
|
}
|
|
}
|
|
if (skipConfirm) {
|
|
ImGui::Spacing();
|
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
|
ImGui::TextUnformatted("You have not backed up your seed — funds could be lost. "
|
|
"Skip anyway?");
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopTextWrapPos();
|
|
}
|
|
} else if (step == 3) {
|
|
// ── Restore from an existing seed phrase ────────────────────────────────
|
|
ImGui::PushFont(ui::material::Type().subtitle1());
|
|
ImGui::TextUnformatted(TR("lite_restore_title"));
|
|
ImGui::PopFont();
|
|
ImGui::Spacing();
|
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
|
|
ImGui::TextUnformatted(TR("lite_restore_intro"));
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::Spacing();
|
|
|
|
if (restoring) {
|
|
// Async restore (with server failover) in flight — driven by pumpAsyncOpen().
|
|
ImGui::TextUnformatted("Restoring your wallet\xE2\x80\xA6");
|
|
if (lite_wallet_->walletOpen()) {
|
|
ui::Notifications::instance().success(TR("lite_restore_ok"), 6.0f);
|
|
finish(); // wipes restoreSeed; the wallet then syncs from the lite server
|
|
} else if (!lite_wallet_->openInProgress() &&
|
|
!lite_wallet_->lastOpenError().empty()) {
|
|
restoreErr = lite_wallet_->lastOpenError();
|
|
restoring = false; // back to the form so the user can fix and retry
|
|
}
|
|
} else {
|
|
ImGui::TextUnformatted(TR("lite_restore_seed_label"));
|
|
ImGui::InputTextMultiline("##LiteRestoreSeed", restoreSeed, sizeof(restoreSeed),
|
|
ImVec2(380.0f, ImGui::GetTextLineHeight() * 3.2f));
|
|
ImGui::Spacing();
|
|
ImGui::TextUnformatted(TR("lite_restore_birthday_label"));
|
|
ImGui::SetNextItemWidth(160.0f);
|
|
ImGui::InputInt("##LiteRestoreBirthday", &restoreBirthday);
|
|
if (restoreBirthday < 0) restoreBirthday = 0;
|
|
|
|
if (!restoreErr.empty()) {
|
|
ImGui::Spacing();
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
|
|
ImGui::TextUnformatted(restoreErr.c_str());
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::PopStyleColor();
|
|
}
|
|
ImGui::Spacing(); ImGui::Spacing();
|
|
|
|
// Trim surrounding whitespace from the entered seed.
|
|
std::string seedTrim(restoreSeed);
|
|
while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.front())) seedTrim.erase(seedTrim.begin());
|
|
while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.back())) seedTrim.pop_back();
|
|
|
|
// Require a valid BIP39 word count before enabling Restore — otherwise a truncated or
|
|
// garbage phrase (previously any non-empty text passed) is submitted and fails opaquely.
|
|
int seedWords = 0;
|
|
{ bool inWord = false;
|
|
for (char c : seedTrim) {
|
|
bool sp = (c == ' ' || c == '\t' || c == '\n' || c == '\r');
|
|
if (!sp && !inWord) { seedWords++; inWord = true; }
|
|
else if (sp) inWord = false;
|
|
} }
|
|
bool seedLenOk = (seedWords == 12 || seedWords == 15 || seedWords == 18 ||
|
|
seedWords == 21 || seedWords == 24);
|
|
if (!seedTrim.empty() && !seedLenOk) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::Warning()));
|
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
|
|
ImGui::TextUnformatted(("Recovery phrase should be 24 words — you have " +
|
|
std::to_string(seedWords) + ".").c_str());
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::PopStyleColor();
|
|
ImGui::Spacing();
|
|
}
|
|
|
|
ImGui::BeginDisabled(!seedLenOk);
|
|
if (ui::material::TactileButton(TR("lite_restore_btn"), ImVec2(btnW, 0))) {
|
|
wallet::LiteWalletRestoreRequest req;
|
|
req.seedPhrase = seedTrim;
|
|
req.birthday = static_cast<unsigned long long>(std::max(0, restoreBirthday));
|
|
req.overwrite = lite_wallet_->walletExists(); // replace any existing wallet file
|
|
if (lite_wallet_->beginRestoreWalletAsync(std::move(req))) {
|
|
restoreErr.clear();
|
|
restoring = true;
|
|
} else {
|
|
restoreErr = lite_wallet_->lastOpenError().empty()
|
|
? std::string("Could not start restore")
|
|
: lite_wallet_->lastOpenError();
|
|
}
|
|
}
|
|
ImGui::EndDisabled();
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton("Back", ImVec2(80, 0))) {
|
|
sodium_memzero(restoreSeed, sizeof(restoreSeed));
|
|
restoreErr.clear();
|
|
step = 0;
|
|
}
|
|
}
|
|
}
|
|
ImGui::EndPopup();
|
|
}
|
|
}
|
|
|
|
void App::renderLiteUnlockPrompt()
|
|
{
|
|
if (!lite_wallet_) return;
|
|
static char pass[128] = "";
|
|
|
|
if (lite_unlock_prompt_ && !ImGui::IsPopupOpen("##LiteUnlock")) {
|
|
ImGui::OpenPopup("##LiteUnlock");
|
|
}
|
|
ImGuiViewport* vp = ImGui::GetMainViewport();
|
|
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f),
|
|
ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
|
if (ImGui::BeginPopupModal("##LiteUnlock", nullptr,
|
|
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
|
|
ImGui::PushFont(ui::material::Type().subtitle1());
|
|
ImGui::TextUnformatted(TR("lite_unlock_title"));
|
|
ImGui::PopFont();
|
|
ImGui::Spacing();
|
|
ImGui::TextUnformatted(TR("lite_unlock_msg"));
|
|
ImGui::Spacing();
|
|
ImGui::SetNextItemWidth(280.0f);
|
|
const bool entered = ImGui::InputText("##LiteUnlockPassModal", pass, sizeof(pass),
|
|
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_EnterReturnsTrue);
|
|
ImGui::Spacing();
|
|
const float btnW = 130.0f;
|
|
bool doUnlock = ui::material::TactileButton(TR("lite_unlock_btn"), ImVec2(btnW, 0)) || entered;
|
|
if (doUnlock) {
|
|
const bool ok = lite_wallet_->unlockWallet(pass);
|
|
sodium_memzero(pass, sizeof(pass));
|
|
if (ok) ui::Notifications::instance().success(TR("lite_unlock_ok"), 5.0f);
|
|
else {
|
|
wallet::liteLog(std::string("Unlock failed: ") + TR("lite_unlock_failed"));
|
|
ui::Notifications::instance().error(TR("lite_unlock_failed"));
|
|
}
|
|
lite_unlock_prompt_ = false;
|
|
ImGui::CloseCurrentPopup();
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 0))) {
|
|
sodium_memzero(pass, sizeof(pass));
|
|
lite_unlock_prompt_ = false;
|
|
ImGui::CloseCurrentPopup();
|
|
}
|
|
ImGui::EndPopup();
|
|
}
|
|
}
|
|
|
|
// Group an integer height with thousands separators, e.g. 1234567 -> "1,234,567".
|
|
static std::string formatIntWithCommas(long long v)
|
|
{
|
|
std::string s = std::to_string(v);
|
|
for (int pos = (int)s.length() - 3; pos > 0; pos -= 3) s.insert(pos, ",");
|
|
return s;
|
|
}
|
|
|
|
// A send-tab-style slider (mirrors RenderAmountBar in send_tab.cpp: rounded track, accent fill,
|
|
// centered % label, glass thumb) for the optional rescan start height, stacked above a full-width
|
|
// numeric field for exact entry. Both share `buf` — the height as a decimal string — so it's the
|
|
// single source of truth. `tipHeight` is the chain tip = the slider's max; when <= 0 (height unknown)
|
|
// the slider is omitted and only the field shows. `availW` is the interior width (bar + field span it).
|
|
static void RenderScanHeightControl(char* buf, size_t bufSize, int tipHeight, bool enabled,
|
|
float dp, float availW)
|
|
{
|
|
namespace m = ui::material;
|
|
int cur = buf[0] ? std::atoi(buf) : 0;
|
|
if (cur < 0) cur = 0;
|
|
|
|
ImGui::BeginDisabled(!enabled);
|
|
|
|
if (tipHeight > 0) {
|
|
const float barW = availW; // slider spans the panel interior, stacked above the field
|
|
const float barH = ImGui::GetFrameHeight(); // match the numeric field's height (already dp-scaled)
|
|
const float barRound = barH * 0.5f;
|
|
const float thumbR = barH * 0.5f;
|
|
|
|
const ImVec2 barMin = ImGui::GetCursorScreenPos();
|
|
const ImVec2 barMax(barMin.x + barW, barMin.y + barH);
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
|
|
// Reserve + interact first so the fill/thumb drawn below reflect the current drag (no lag).
|
|
ImGui::InvisibleButton("##scanbar", ImVec2(barW, barH));
|
|
const bool active = enabled && ImGui::IsItemActive();
|
|
const bool hovered = ImGui::IsItemHovered();
|
|
if (active) {
|
|
const float clickPct = std::clamp((ImGui::GetIO().MousePos.x - barMin.x) / barW, 0.0f, 1.0f);
|
|
cur = (int)lroundf(clickPct * (float)tipHeight);
|
|
snprintf(buf, bufSize, "%d", cur);
|
|
}
|
|
const float pct = std::clamp((float)cur / (float)tipHeight, 0.0f, 1.0f);
|
|
|
|
// Track + accent fill up to the thumb (fill's rounded right edge matches the thumb circle).
|
|
dl->AddRectFilled(barMin, barMax, IM_COL32(255, 255, 255, enabled ? 26 : 12), barRound);
|
|
const float usableW = barW - thumbR * 2.0f;
|
|
const float thumbCenterX = barMin.x + thumbR + usableW * pct;
|
|
const float fillRight = thumbCenterX + thumbR;
|
|
if (fillRight > barMin.x + barRound * 2.0f)
|
|
dl->AddRectFilled(barMin, ImVec2(fillRight, barMax.y),
|
|
m::WithAlpha(m::Primary(), enabled ? 150 : 55), barRound);
|
|
|
|
// Percentage-of-chain label centered on the bar (mirrors the send-tab amount bar).
|
|
{
|
|
ImFont* cap = m::Type().caption();
|
|
char pctBuf[16];
|
|
snprintf(pctBuf, sizeof(pctBuf), "%.0f%%", pct * 100.0f);
|
|
const ImVec2 tsz = cap->CalcTextSizeA(cap->LegacySize, 10000.0f, 0.0f, pctBuf);
|
|
dl->AddText(cap, cap->LegacySize,
|
|
ImVec2(barMin.x + (barW - tsz.x) * 0.5f, barMin.y + (barH - tsz.y) * 0.5f),
|
|
IM_COL32(255, 255, 255, enabled ? 190 : 80), pctBuf);
|
|
}
|
|
|
|
// Glass thumb (drawn last so it sits above the fill + label).
|
|
const float thumbY = barMin.y + barH * 0.5f;
|
|
dl->AddCircleFilled(ImVec2(thumbCenterX, thumbY), thumbR, IM_COL32(255, 255, 255, enabled ? 205 : 90), 24);
|
|
dl->AddCircle(ImVec2(thumbCenterX, thumbY), thumbR, IM_COL32(255, 255, 255, enabled ? 120 : 50), 24, 1.4f * dp);
|
|
|
|
if (hovered && enabled) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
m::Tooltip("%s / %s (%.0f%%)", formatIntWithCommas(cur).c_str(),
|
|
formatIntWithCommas(tipHeight).c_str(), pct * 100.0f);
|
|
}
|
|
}
|
|
|
|
// Exact numeric entry — full-width beneath the slider.
|
|
ImGui::SetNextItemWidth(availW);
|
|
ImGui::InputText("##importscanheight", buf, bufSize, ImGuiInputTextFlags_CharsDecimal);
|
|
|
|
ImGui::EndDisabled();
|
|
}
|
|
|
|
// Hover preview for the Paste button: peek at the clipboard, classify it in the dialog's current
|
|
// key-type mode, and show a validity line + a preview masked with '*' unless the key is currently
|
|
// revealed — so the user can verify the clipboard holds a valid key before pasting it.
|
|
static void RenderPasteHoverPreview(bool viewMode, bool reveal, float dp)
|
|
{
|
|
namespace m = ui::material;
|
|
const char* clipRaw = ImGui::GetClipboardText();
|
|
std::string key(clipRaw ? clipRaw : "");
|
|
while (!key.empty() && (key.front()==' '||key.front()=='\t'||key.front()=='\n'||key.front()=='\r')) key.erase(key.begin());
|
|
while (!key.empty() && (key.back()==' '||key.back()=='\t'||key.back()=='\n'||key.back()=='\r')) key.pop_back();
|
|
|
|
ImGui::BeginTooltip();
|
|
ImGui::TextUnformatted(TR("paste"));
|
|
|
|
if (key.empty()) {
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", TR("paste_clip_empty"));
|
|
ImGui::EndTooltip();
|
|
return;
|
|
}
|
|
|
|
const bool isView = services::WalletSecurityController::isViewingKey(key);
|
|
const bool isSpend = services::WalletSecurityController::isRecognizedPrivateKey(key);
|
|
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
|
|
== services::WalletSecurityController::KeyKind::Shielded;
|
|
const bool recognized = viewMode ? isView : isSpend; // valid for THIS dialog's mode
|
|
const bool wrongType = viewMode ? isSpend : isView; // a key, but for the other button
|
|
|
|
// valid / wrong-type / not-a-key. The field indicator softens the last case to amber+HELP, but this
|
|
// is a verify-before-paste check, so a truly-unrecognized clipboard gets a deliberately stronger
|
|
// red ✗ "don't paste" signal (amber ⚠ stays reserved for a real key aimed at the other button).
|
|
const ImVec4 amber(0.8f, 0.6f, 0.0f, 1.0f); // matches the field indicator's wrong-type amber
|
|
const ImVec4 red = m::ErrorVec4();
|
|
ImGui::PushFont(m::Type().iconSmall());
|
|
if (recognized) ImGui::TextColored(m::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
|
|
else if (wrongType) ImGui::TextColored(amber, ICON_MD_WARNING);
|
|
else ImGui::TextColored(red, ICON_MD_CANCEL);
|
|
ImGui::PopFont();
|
|
ImGui::SameLine(0, 4.0f * dp);
|
|
if (recognized) {
|
|
const char* label = viewMode ? TR("import_key_type_zview")
|
|
: shielded ? TR("import_key_type_zspend")
|
|
: TR("import_key_type_tkey");
|
|
ImGui::TextColored(m::SuccessVec4(), "%s", label);
|
|
} else if (wrongType) {
|
|
ImGui::TextColored(amber, "%s", viewMode ? TR("import_viewkey_wrong_type") : TR("import_key_wrong_type"));
|
|
} else {
|
|
ImGui::TextColored(red, "%s", TR("import_key_type_unknown"));
|
|
}
|
|
|
|
// Preview line — masked with '*' unless the key is currently revealed (then a truncated peek).
|
|
std::string preview;
|
|
if (reveal) {
|
|
preview = key.size() > 22 ? key.substr(0, 14) + "..." + key.substr(key.size() - 6) : key;
|
|
} else {
|
|
const size_t n = std::min(key.size(), (size_t)32);
|
|
preview.assign(n, '*');
|
|
if (key.size() > n) preview += "...";
|
|
}
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", preview.c_str());
|
|
|
|
ImGui::EndTooltip();
|
|
}
|
|
|
|
void App::renderImportKeyDialog()
|
|
{
|
|
namespace m = ui::material;
|
|
float dp = ui::Layout::dpiScale();
|
|
const bool viewMode = import_view_mode_; // watch-only shielded viewing key vs. spending key
|
|
|
|
m::OverlayDialogSpec ov;
|
|
ov.title = viewMode ? TR("import_viewkey_title") : TR("import_key_title");
|
|
ov.p_open = &show_import_key_;
|
|
ov.style = m::OverlayStyle::BlurFloat;
|
|
ov.cardWidth = 520.0f;
|
|
ov.idSuffix = "importkey";
|
|
if (!m::BeginOverlayDialog(ov)) return;
|
|
|
|
// Fresh dialog on each open (the key buffer was already wiped on the last close).
|
|
if (ImGui::IsWindowAppearing()) {
|
|
import_success_ = false;
|
|
import_status_.clear();
|
|
import_result_address_.clear();
|
|
import_key_reveal_ = false;
|
|
sodium_memzero(import_key_scan_height_, sizeof(import_key_scan_height_));
|
|
// Preserve a sweep that is still Running OR has a terminal result yet to be seen (a sweep can
|
|
// finish while the dialog is closed) — reset only when there's genuinely nothing to show, so a
|
|
// completed sweep's Done/Error panel survives a close+reopen. It's cleared on close (below).
|
|
if (sweep_step_ == SweepStep::Idle) {
|
|
import_sweep_mode_ = false;
|
|
sweep_dest_mode_ = 0;
|
|
sweep_dest_pick_[0] = '\0';
|
|
sweep_status_.clear();
|
|
sweep_txid_.clear();
|
|
sweep_dest_shown_.clear();
|
|
}
|
|
}
|
|
const bool sweepRunning = (sweep_step_ == SweepStep::Running);
|
|
// Sweep UI is only valid for a spending-key (non-view) dialog; masking by !viewMode stops preserved
|
|
// sweep state from bleeding a "Sweep" button / progress panel into the watch-only viewing dialog.
|
|
const bool sweepMode = import_sweep_mode_ && !viewMode;
|
|
// Esc dismisses (this overlay has no built-in Esc handling).
|
|
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) show_import_key_ = false;
|
|
|
|
// Handle-with-care note. A spending key grants access to funds (strong warning); a viewing key
|
|
// is watch-only (milder, informational eye note).
|
|
if (viewMode) {
|
|
ImGui::PushFont(m::Type().iconSmall());
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), ICON_MD_VISIBILITY);
|
|
ImGui::PopFont();
|
|
ImGui::SameLine(0, 6.0f * dp);
|
|
ImGui::PushTextWrapPos(0.0f);
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_viewkey_note"));
|
|
ImGui::PopTextWrapPos();
|
|
} else {
|
|
m::DialogWarningHeader(TR("import_key_warn"));
|
|
}
|
|
ImGui::Spacing();
|
|
|
|
// A running node is required — say so up front instead of failing only after Import.
|
|
if (!state_.connected) {
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_key_need_node"));
|
|
ImGui::Spacing();
|
|
}
|
|
|
|
// Masked key field; the reveal (eye) toggle sits frameless flush at its right edge, with frameless
|
|
// Paste / Clear icon actions beneath — lighter and less cluttered than three boxed buttons.
|
|
ImGui::TextUnformatted(viewMode ? TR("import_viewkey_field") : TR("import_key_field"));
|
|
const float fieldH = ImGui::GetFrameHeight();
|
|
const ImVec2 iconSz(fieldH, fieldH);
|
|
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - fieldH);
|
|
ImGuiInputTextFlags flags = import_key_reveal_ ? 0 : ImGuiInputTextFlags_Password;
|
|
ImGui::InputText("##importkey", import_key_input_, sizeof(import_key_input_), flags);
|
|
ImGui::SameLine(0, 0);
|
|
m::IconButtonStyle eyeSt;
|
|
eyeSt.hoverBg = m::WithAlpha(m::OnSurface(), 30);
|
|
eyeSt.bgRounding = 4.0f * dp; // match the input frame's corners (it sits flush at the field's edge)
|
|
eyeSt.tooltip = TR("import_key_reveal_tip");
|
|
if (m::IconButton("##revealkey", import_key_reveal_ ? ICON_MD_VISIBILITY_OFF : ICON_MD_VISIBILITY,
|
|
m::Type().iconSmall(), iconSz, eyeSt))
|
|
import_key_reveal_ = !import_key_reveal_;
|
|
|
|
m::IconButtonStyle actSt;
|
|
actSt.restBg = m::WithAlpha(m::OnSurface(), 14); // faint pill so it still reads as a button
|
|
actSt.hoverBg = m::WithAlpha(m::OnSurface(), 34);
|
|
// Paste has no static tooltip — hovering shows a live clipboard preview that classifies the key
|
|
// (masked with '*' when the key isn't revealed) so the user can verify it before pasting.
|
|
const bool pasteClicked = m::IconButton("##pastekey", ICON_MD_CONTENT_PASTE, m::Type().iconSmall(), iconSz, actSt);
|
|
if (ImGui::IsItemHovered()) RenderPasteHoverPreview(viewMode, import_key_reveal_, dp);
|
|
if (pasteClicked) {
|
|
const char* clipboard = ImGui::GetClipboardText();
|
|
if (clipboard) {
|
|
std::string trimmed(clipboard);
|
|
while (!trimmed.empty() && (trimmed.front()==' '||trimmed.front()=='\t'||trimmed.front()=='\n'||trimmed.front()=='\r')) trimmed.erase(trimmed.begin());
|
|
while (!trimmed.empty() && (trimmed.back()==' '||trimmed.back()=='\t'||trimmed.back()=='\n'||trimmed.back()=='\r')) trimmed.pop_back();
|
|
snprintf(import_key_input_, sizeof(import_key_input_), "%s", trimmed.c_str());
|
|
}
|
|
}
|
|
ImGui::SameLine(0, ui::Layout::spacingSm());
|
|
actSt.tooltip = TR("clear");
|
|
if (m::IconButton("##clearkey", ICON_MD_CLEAR, m::Type().iconSmall(), iconSz, actSt))
|
|
sodium_memzero(import_key_input_, sizeof(import_key_input_));
|
|
|
|
// Trimmed view + classification. The indicator and the Import guard share isRecognizedImportKey.
|
|
std::string keyTrim(import_key_input_);
|
|
while (!keyTrim.empty() && (keyTrim.front()==' '||keyTrim.front()=='\t'||keyTrim.front()=='\n'||keyTrim.front()=='\r')) keyTrim.erase(keyTrim.begin());
|
|
while (!keyTrim.empty() && (keyTrim.back()==' '||keyTrim.back()=='\t'||keyTrim.back()=='\n'||keyTrim.back()=='\r')) keyTrim.pop_back();
|
|
bool isView = services::WalletSecurityController::isViewingKey(keyTrim);
|
|
bool isSpend = services::WalletSecurityController::isRecognizedPrivateKey(keyTrim);
|
|
bool shielded = services::WalletSecurityController::classifyPrivateKey(keyTrim)
|
|
== services::WalletSecurityController::KeyKind::Shielded;
|
|
// Each button accepts only its own key type; a key valid for the OTHER button is flagged with a
|
|
// redirect hint rather than lumped in with truly unrecognized input.
|
|
bool recognized = viewMode ? isView : isSpend;
|
|
bool wrongType = viewMode ? isSpend : isView;
|
|
|
|
if (!keyTrim.empty()) {
|
|
if (recognized) {
|
|
const char* label = viewMode ? TR("import_key_type_zview")
|
|
: shielded ? TR("import_key_type_zspend")
|
|
: TR("import_key_type_tkey");
|
|
ImGui::PushFont(m::Type().iconSmall());
|
|
ImGui::TextColored(m::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
|
|
ImGui::PopFont();
|
|
ImGui::SameLine(0, 4.0f * dp);
|
|
ImGui::TextColored(m::SuccessVec4(), "%s", label);
|
|
} else {
|
|
const char* msg = wrongType
|
|
? (viewMode ? TR("import_viewkey_wrong_type") : TR("import_key_wrong_type"))
|
|
: TR("import_key_type_unknown");
|
|
ImGui::PushFont(m::Type().iconSmall());
|
|
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), wrongType ? ICON_MD_WARNING : ICON_MD_HELP);
|
|
ImGui::PopFont();
|
|
ImGui::SameLine(0, 4.0f * dp);
|
|
ImGui::PushTextWrapPos(0.0f);
|
|
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), "%s", msg);
|
|
ImGui::PopTextWrapPos();
|
|
}
|
|
}
|
|
ImGui::Spacing();
|
|
|
|
// Sweep option (spending keys only): rather than keeping the key, import it, move ALL its funds to
|
|
// an address you own, and leave the (now-empty) key behind. A viewing key can't sign, so no sweep.
|
|
if (!viewMode) {
|
|
// Lock the mode + destination for the lifetime of a running sweep so the user can't desync the
|
|
// UI from the in-flight operation (unchecking mid-run would otherwise re-enable Import).
|
|
ImGui::BeginDisabled(sweepRunning);
|
|
ImGui::Checkbox(TR("sweep_toggle"), &import_sweep_mode_);
|
|
if (import_sweep_mode_) {
|
|
ImGui::PushTextWrapPos(0.0f);
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", TR("sweep_caveat"));
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::Spacing();
|
|
ImGui::TextUnformatted(TR("sweep_dest_label"));
|
|
std::string curSel = (sweep_dest_mode_ == 1 && sweep_dest_pick_[0])
|
|
? util::truncateMiddle(sweep_dest_pick_, 18, 8)
|
|
: std::string(TR("sweep_dest_new"));
|
|
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
|
|
if (ImGui::BeginCombo("##sweepdest", curSel.c_str())) {
|
|
if (ImGui::Selectable(TR("sweep_dest_new"), sweep_dest_mode_ == 0)) {
|
|
sweep_dest_mode_ = 0;
|
|
sweep_dest_pick_[0] = '\0';
|
|
}
|
|
const bool anyOwn = !state_.z_addresses.empty() || !state_.t_addresses.empty();
|
|
if (anyOwn) ImGui::Separator();
|
|
auto addrItem = [&](const std::string& a) {
|
|
const bool sel = (sweep_dest_mode_ == 1 && a == sweep_dest_pick_);
|
|
if (ImGui::Selectable(util::truncateMiddle(a, 22, 10).c_str(), sel)) {
|
|
sweep_dest_mode_ = 1;
|
|
snprintf(sweep_dest_pick_, sizeof(sweep_dest_pick_), "%s", a.c_str());
|
|
}
|
|
};
|
|
for (const auto& a : state_.z_addresses) addrItem(a.address);
|
|
for (const auto& a : state_.t_addresses) addrItem(a.address);
|
|
ImGui::EndCombo();
|
|
}
|
|
}
|
|
ImGui::EndDisabled();
|
|
ImGui::Spacing();
|
|
}
|
|
|
|
// Optional scan-from-height, grouped in a subtle glass panel so it reads as one distinct,
|
|
// clearly-optional block. z_importkey / z_importviewingkey accept a start block, so a shielded
|
|
// import of a recent key needn't rescan the whole chain. Transparent WIF (importprivkey) has no
|
|
// start-height parameter, so the control is disabled + noted once a transparent key is recognized.
|
|
ImGui::Dummy(ImVec2(0, 2.0f * dp));
|
|
{
|
|
const bool transparentKey = !viewMode && isSpend && !shielded; // recognized transparent WIF
|
|
if (transparentKey && import_key_scan_height_[0])
|
|
sodium_memzero(import_key_scan_height_, sizeof(import_key_scan_height_)); // no stale height
|
|
const int tip = state_.sync.blocks; // chain tip = slider max; 0 when unknown
|
|
|
|
// Render the content on an upper draw channel, then paint the glass panel behind it once the
|
|
// block's height is known (immediate-mode auto-sized panel via a draw-list splitter).
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
ImDrawListSplitter splitter;
|
|
splitter.Split(dl, 2);
|
|
splitter.SetCurrentChannel(dl, 1);
|
|
|
|
const float padX = 12.0f * dp, padY = 10.0f * dp;
|
|
const float panelW = ImGui::GetContentRegionAvail().x;
|
|
const ImVec2 panelMin = ImGui::GetCursorScreenPos();
|
|
|
|
ImGui::Dummy(ImVec2(0, padY));
|
|
ImGui::Indent(padX);
|
|
const float availW = ImGui::GetContentRegionAvail().x - padX; // interior width (both side paddings)
|
|
|
|
// Label + current chain height, right-aligned within the interior.
|
|
const float rowStartX = ImGui::GetCursorPosX();
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_scan_label"));
|
|
if (tip > 0) {
|
|
char tipBuf[64];
|
|
snprintf(tipBuf, sizeof(tipBuf), "%s %s", TR("import_scan_tip"), formatIntWithCommas(tip).c_str());
|
|
const float targetX = rowStartX + availW - ImGui::CalcTextSize(tipBuf).x;
|
|
ImGui::SameLine();
|
|
if (targetX > ImGui::GetCursorPosX()) ImGui::SetCursorPosX(targetX);
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", tipBuf);
|
|
}
|
|
|
|
RenderScanHeightControl(import_key_scan_height_, sizeof(import_key_scan_height_),
|
|
tip, !transparentKey, dp, availW);
|
|
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s",
|
|
transparentKey ? TR("import_scan_transparent") : TR("import_scan_hint"));
|
|
|
|
ImGui::Unindent(padX);
|
|
ImGui::Dummy(ImVec2(0, padY));
|
|
|
|
const ImVec2 panelMax(panelMin.x + panelW, ImGui::GetCursorScreenPos().y);
|
|
splitter.SetCurrentChannel(dl, 0);
|
|
m::GlassPanelSpec spec;
|
|
spec.rounding = 8.0f * dp;
|
|
spec.fillAlpha = 20;
|
|
spec.borderAlpha = 30;
|
|
m::DrawGlassPanel(dl, panelMin, panelMax, spec);
|
|
splitter.Merge(dl);
|
|
}
|
|
ImGui::Dummy(ImVec2(0, 2.0f * dp));
|
|
|
|
// Progress / result / error.
|
|
if (sweepMode) {
|
|
if (sweepRunning) {
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::Primary()), "%s%s",
|
|
sweep_status_.c_str(), m::LoadingDots());
|
|
} else if (sweep_step_ == SweepStep::Done) {
|
|
if (import_key_input_[0]) sodium_memzero(import_key_input_, sizeof(import_key_input_)); // wipe on success
|
|
ImGui::PushFont(m::Type().iconSmall());
|
|
ImGui::TextColored(m::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
|
|
ImGui::PopFont();
|
|
ImGui::SameLine(0, 4.0f * dp);
|
|
ImGui::TextColored(m::SuccessVec4(), "%s", TR("sweep_done"));
|
|
if (!sweep_dest_shown_.empty()) {
|
|
ImGui::Spacing();
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("sweep_to"));
|
|
ui::widgets::AddressCopyField("##sweptto", sweep_dest_shown_);
|
|
}
|
|
if (!sweep_txid_.empty()) {
|
|
ImGui::Spacing();
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("sweep_tx"));
|
|
ui::widgets::AddressCopyField("##sweeptx", sweep_txid_);
|
|
}
|
|
} else if (sweep_step_ == SweepStep::Error && !sweep_status_.empty()) {
|
|
ImGui::PushTextWrapPos(0.0f);
|
|
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", sweep_status_.c_str());
|
|
ImGui::PopTextWrapPos();
|
|
}
|
|
} else if (import_in_progress_) {
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::Primary()), "%s%s",
|
|
TR("import_key_rescanning"), m::LoadingDots());
|
|
} else if (import_success_) {
|
|
ImGui::PushFont(m::Type().iconSmall());
|
|
ImGui::TextColored(m::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
|
|
ImGui::PopFont();
|
|
ImGui::SameLine(0, 4.0f * dp);
|
|
ImGui::TextColored(m::SuccessVec4(), "%s", TR("import_key_done"));
|
|
if (!import_result_address_.empty()) {
|
|
ImGui::Spacing();
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_key_address"));
|
|
ui::widgets::AddressCopyField("##importedaddr", import_result_address_);
|
|
}
|
|
} else if (!import_status_.empty()) {
|
|
ImGui::PushTextWrapPos(0.0f);
|
|
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", import_status_.c_str());
|
|
ImGui::PopTextWrapPos();
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
ImGui::Separator();
|
|
ImGui::Spacing();
|
|
|
|
float btnW = 130.0f * dp;
|
|
const bool running = sweepMode ? sweepRunning : import_in_progress_;
|
|
// Sweep needs a spending key AND a destination (a picked address when not using a fresh one).
|
|
const bool destReady = !sweepMode || sweep_dest_mode_ == 0 || sweep_dest_pick_[0] != '\0';
|
|
const bool canAct = recognized && state_.connected && !running && destReady;
|
|
ImGui::BeginDisabled(!canAct);
|
|
if (m::TactileButton(sweepMode ? TR("sweep_button") : TR("import_key_import"), ImVec2(btnW, 0))) {
|
|
int startHeight = 0; // 0 = full rescan. Shielded imports (z_importkey) honor it; transparent
|
|
if (import_key_scan_height_[0]) { // WIF (importprivkey) ignores it.
|
|
startHeight = std::atoi(import_key_scan_height_);
|
|
if (startHeight < 0) startHeight = 0;
|
|
if (state_.sync.blocks > 0 && startHeight > state_.sync.blocks)
|
|
startHeight = state_.sync.blocks; // never past the chain tip
|
|
}
|
|
if (sweepMode) {
|
|
sweepPrivateKey(std::string(import_key_input_), startHeight, sweep_dest_mode_,
|
|
sweep_dest_mode_ == 1 ? std::string(sweep_dest_pick_) : std::string());
|
|
} else {
|
|
import_in_progress_ = true;
|
|
import_success_ = false;
|
|
import_status_.clear();
|
|
import_result_address_.clear();
|
|
importPrivateKey(std::string(import_key_input_), startHeight,
|
|
[this](bool ok, const std::string& err, const std::string& addr) {
|
|
import_in_progress_ = false;
|
|
import_success_ = ok;
|
|
if (ok) {
|
|
import_result_address_ = addr;
|
|
import_status_.clear();
|
|
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // wipe on success
|
|
} else {
|
|
import_status_ = err;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
ImGui::EndDisabled();
|
|
ImGui::SameLine();
|
|
if (m::TactileButton(TR("close"), ImVec2(btnW, 0))) show_import_key_ = false;
|
|
|
|
// Wipe the key the moment the dialog closes (Close button OR outside-click, which clears
|
|
// show_import_key_ mid-frame). in_progress is left to the callback to clear.
|
|
if (!show_import_key_) {
|
|
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // key already copied into the op
|
|
sodium_memzero(import_key_scan_height_, sizeof(import_key_scan_height_));
|
|
import_status_.clear();
|
|
import_result_address_.clear();
|
|
import_success_ = false;
|
|
import_key_reveal_ = false;
|
|
// A running sweep is an async daemon op that keeps going in the background — preserve its UI
|
|
// state so reopening shows the running progress and, on completion, the Done/Error panel.
|
|
if (sweep_step_ != SweepStep::Running) {
|
|
import_sweep_mode_ = false;
|
|
sweep_dest_mode_ = 0;
|
|
sweep_dest_pick_[0] = '\0';
|
|
sweep_status_.clear();
|
|
sweep_txid_.clear();
|
|
sweep_dest_shown_.clear();
|
|
sweep_step_ = SweepStep::Idle;
|
|
}
|
|
}
|
|
|
|
m::EndOverlayDialog();
|
|
}
|
|
|
|
void App::renderExportKeyDialog()
|
|
{
|
|
auto dlg = ui::schema::UI().drawElement("inline-dialogs", "export-key");
|
|
auto dlgF = [&](const char* key, float fb) -> float {
|
|
auto it = dlg.extraFloats.find(key);
|
|
return it != dlg.extraFloats.end() ? it->second : fb;
|
|
};
|
|
int btnFont = (int)dlgF("button-font", 1);
|
|
|
|
if (!ui::material::BeginOverlayDialog("Export Private Key", &show_export_key_, dlgF("width", 600.0f), 0.94f)) {
|
|
return;
|
|
}
|
|
|
|
ImGui::TextColored(ImVec4(0.9f, 0.4f, 0.4f, 1.0f),
|
|
"WARNING: Anyone with this key can spend your coins!");
|
|
ImGui::Spacing();
|
|
|
|
// Address selector
|
|
ImGui::Text("Select Address:");
|
|
std::vector<std::string> all_addrs;
|
|
for (const auto& a : state_.t_addresses) all_addrs.push_back(a.address);
|
|
for (const auto& a : state_.z_addresses) all_addrs.push_back(a.address);
|
|
|
|
int addrFrontLen = (int)dlgF("addr-front-len", 20);
|
|
int addrBackLen = (int)dlgF("addr-back-len", 8);
|
|
if (ImGui::BeginCombo("##exportaddr", export_address_.empty() ? "Select address..." : export_address_.c_str())) {
|
|
for (const auto& addr : all_addrs) {
|
|
bool selected = (export_address_ == addr);
|
|
std::string display = util::truncateMiddle(addr, addrFrontLen, addrBackLen);
|
|
if (ImGui::Selectable(display.c_str(), selected)) {
|
|
export_address_ = addr;
|
|
export_result_.clear();
|
|
}
|
|
}
|
|
ImGui::EndCombo();
|
|
}
|
|
|
|
ImGui::SameLine();
|
|
if (ui::material::StyledButton("Export", ImVec2(0, 0), ui::material::resolveButtonFont(btnFont))) {
|
|
if (!export_address_.empty()) {
|
|
exportPrivateKey(export_address_, [this](const std::string& key) {
|
|
export_result_ = key;
|
|
});
|
|
}
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
|
|
if (!export_result_.empty()) {
|
|
ImGui::Text("Private Key:");
|
|
ImGui::InputTextMultiline("##exportresult", (char*)export_result_.c_str(),
|
|
export_result_.size() + 1, ImVec2(-1, dlgF("key-display-height", 60.0f)), ImGuiInputTextFlags_ReadOnly);
|
|
|
|
if (ui::material::StyledButton("Copy to Clipboard", ImVec2(0, 0), ui::material::resolveButtonFont(btnFont))) {
|
|
ImGui::SetClipboardText(export_result_.c_str());
|
|
}
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
ImGui::Separator();
|
|
|
|
int closeBtnFont = (int)dlgF("close-button-font", -1);
|
|
if (closeBtnFont < 0) closeBtnFont = btnFont;
|
|
if (ui::material::StyledButton("Close", ImVec2(dlgF("close-button-width", 120.0f), 0), ui::material::resolveButtonFont(closeBtnFont))) {
|
|
show_export_key_ = false;
|
|
export_result_.clear();
|
|
export_address_.clear();
|
|
}
|
|
|
|
ui::material::EndOverlayDialog();
|
|
}
|
|
|
|
void App::renderBackupDialog()
|
|
{
|
|
auto dlg = ui::schema::UI().drawElement("inline-dialogs", "backup");
|
|
auto dlgF = [&](const char* key, float fb) -> float {
|
|
auto it = dlg.extraFloats.find(key);
|
|
return it != dlg.extraFloats.end() ? it->second : fb;
|
|
};
|
|
int btnFont = (int)dlgF("button-font", 1);
|
|
float btnW = dlgF("button-width", 120.0f);
|
|
|
|
if (!ui::material::BeginOverlayDialog("Backup Wallet", &show_backup_, dlgF("width", 500.0f), 0.94f)) {
|
|
return;
|
|
}
|
|
|
|
ImGui::TextWrapped("Export all private keys to a file. Keep this file secure!");
|
|
ImGui::Spacing();
|
|
|
|
ImGui::Text("Backup File Path:");
|
|
static char backup_path[512] = "dragonx-backup.txt";
|
|
static bool s_backup_confirm_overwrite = false;
|
|
ImGui::SetNextItemWidth(-1);
|
|
if (ImGui::InputText("##backuppath", backup_path, sizeof(backup_path)))
|
|
s_backup_confirm_overwrite = false; // path edited — re-check overwrite on next Save
|
|
|
|
ImGui::Spacing();
|
|
|
|
if (!backup_status_.empty()) {
|
|
if (backup_success_) {
|
|
ImGui::TextColored(ui::material::SuccessVec4(), "%s", backup_status_.c_str());
|
|
} else {
|
|
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", backup_status_.c_str());
|
|
}
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
ImGui::Separator();
|
|
|
|
if (ui::material::StyledButton("Save Backup", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) {
|
|
std::string path(backup_path);
|
|
// Trim surrounding whitespace (a pasted path can carry a trailing newline).
|
|
while (!path.empty() && (path.front()==' '||path.front()=='\t'||path.front()=='\n'||path.front()=='\r')) path.erase(path.begin());
|
|
while (!path.empty() && (path.back()==' '||path.back()=='\t'||path.back()=='\n'||path.back()=='\r')) path.pop_back();
|
|
if (!path.empty()) {
|
|
std::error_code ec;
|
|
if (!s_backup_confirm_overwrite && std::filesystem::exists(path, ec)) {
|
|
// Don't clobber an existing file (possibly a good earlier backup) without confirmation.
|
|
s_backup_confirm_overwrite = true;
|
|
backup_success_ = false;
|
|
backup_status_ = "A file already exists there — click Save Backup again to overwrite it.";
|
|
} else {
|
|
s_backup_confirm_overwrite = false;
|
|
backupWallet(path, [this](bool success, const std::string& msg) {
|
|
backup_success_ = success;
|
|
backup_status_ = msg;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::StyledButton("Close", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) {
|
|
show_backup_ = false;
|
|
backup_status_.clear();
|
|
s_backup_confirm_overwrite = false;
|
|
}
|
|
|
|
ui::material::EndOverlayDialog();
|
|
}
|
|
|
|
void App::maybeOfferDaemonUpdate()
|
|
{
|
|
if (!supportsFullNodeLifecycleActions()) return; // lite / no embedded-daemon build
|
|
if (capture_mode_ || !settings_) return; // never during a UI sweep
|
|
auto inst = resources::getInstalledDaemonInfo();
|
|
auto bun = resources::getBundledDaemonInfo();
|
|
// Only when a daemon is ALREADY installed and its size differs from the bundled one — an absent
|
|
// daemon is extracted normally, and a same-size match needs nothing. Fire once per bundled
|
|
// version (never re-nag a user running a custom node): key on the bundled size last offered.
|
|
if (!inst.exists || !bun.available || inst.size == bun.size) return;
|
|
if ((unsigned long long)settings_->getDaemonUpdatePromptedSize() == bun.size) return;
|
|
daemon_update_bundled_size_ = bun.size;
|
|
show_daemon_update_prompt_ = true;
|
|
}
|
|
|
|
void App::renderDaemonUpdatePrompt()
|
|
{
|
|
if (!ui::material::BeginOverlayDialog(TR("daemon_update_title"), &show_daemon_update_prompt_, 520.0f, 0.94f))
|
|
return;
|
|
const float dp = ui::Layout::dpiScale();
|
|
ImGui::TextWrapped("%s", TR("daemon_update_body"));
|
|
ImGui::Spacing();
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
|
|
ImGui::TextWrapped("%s", TR("daemon_update_safe"));
|
|
ImGui::PopStyleColor();
|
|
ImGui::Spacing();
|
|
ImGui::Separator();
|
|
if (ui::material::StyledButton(TR("daemon_update_now"), ImVec2(170 * dp, 0))) {
|
|
show_daemon_update_prompt_ = false;
|
|
reinstallBundledDaemon(); // stops the node (managed or external), overwrites, restarts
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::StyledButton(TR("daemon_update_keep"), ImVec2(150 * dp, 0))) {
|
|
show_daemon_update_prompt_ = false;
|
|
}
|
|
ui::material::EndOverlayDialog();
|
|
// Any close path (Update / Keep / Esc / X): record the bundled size so we don't prompt again for
|
|
// this wallet version — a user keeping a custom daemon is asked at most once per version.
|
|
if (!show_daemon_update_prompt_ && settings_) {
|
|
settings_->setDaemonUpdatePromptedSize((long long)daemon_update_bundled_size_);
|
|
settings_->save();
|
|
}
|
|
}
|
|
|
|
void App::renderSeedBackupDialog()
|
|
{
|
|
// Wipe the revealed phrase and reset the dialog's state.
|
|
auto closeAndWipe = [this]() {
|
|
if (!seed_backup_phrase_.empty())
|
|
sodium_memzero(&seed_backup_phrase_[0], seed_backup_phrase_.size());
|
|
seed_backup_phrase_.clear();
|
|
seed_backup_status_.clear();
|
|
seed_backup_fetch_started_ = false;
|
|
seed_backup_loading_ = false;
|
|
seed_backup_no_mnemonic_ = false;
|
|
show_seed_backup_ = false;
|
|
};
|
|
|
|
// Kick off the async z_exportmnemonic fetch once, and only while unlocked. Never during a UI
|
|
// sweep — the sweep pre-sets fetch_started_ + a demo phrase, but guard the RPC too.
|
|
if (!seed_backup_fetch_started_ && !state_.isLocked() && !capture_mode_) {
|
|
seed_backup_fetch_started_ = true;
|
|
seed_backup_loading_ = true;
|
|
seed_backup_no_mnemonic_ = false;
|
|
seed_backup_status_.clear();
|
|
exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& phrase,
|
|
const std::string& error) {
|
|
if (!show_seed_backup_) return; // dialog closed while the fetch was in flight
|
|
seed_backup_loading_ = false;
|
|
if (ok) {
|
|
seed_backup_phrase_ = phrase; // working copy — wiped on close
|
|
} else if (noMnemonic) {
|
|
seed_backup_no_mnemonic_ = true;
|
|
} else {
|
|
seed_backup_status_ = error.empty() ? std::string(TR("seed_backup_load_failed")) : error;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!ui::material::BeginOverlayDialog(TR("seed_backup_title"), &show_seed_backup_, 540.0f, 0.94f)) {
|
|
if (seed_backup_fetch_started_) closeAndWipe();
|
|
return;
|
|
}
|
|
// BeginOverlayDialog flips show_seed_backup_ to false on an Esc / outside-click / X dismiss but
|
|
// still returns true this frame — catch that here so the revealed secret is always scrubbed.
|
|
if (!show_seed_backup_) {
|
|
closeAndWipe();
|
|
ui::material::EndOverlayDialog();
|
|
return;
|
|
}
|
|
|
|
// Button widths are logical px; BeginOverlayDialog scales the card by dpiScale() so scale these
|
|
// to match (raw widths render small/left-packed at higher DPI / font scale).
|
|
const float dp = ui::Layout::dpiScale();
|
|
|
|
if (state_.isLocked()) {
|
|
ImGui::TextWrapped("%s", TR("seed_backup_locked"));
|
|
} else if (seed_backup_loading_) {
|
|
ImGui::TextWrapped("%s", TR("seed_backup_loading"));
|
|
} else if (seed_backup_no_mnemonic_) {
|
|
ImGui::TextWrapped("%s", TR("seed_backup_none"));
|
|
} else if (!seed_backup_phrase_.empty()) {
|
|
ImGui::TextWrapped("%s", TR("seed_backup_intro"));
|
|
ImGui::Spacing();
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
|
ImGui::TextWrapped("%s", TR("seed_backup_warning"));
|
|
ImGui::PopStyleColor();
|
|
ImGui::Spacing();
|
|
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_backup_phrase_));
|
|
ImGui::Spacing();
|
|
if (!seed_backup_status_.empty()) {
|
|
ImGui::TextColored(ui::material::SuccessVec4(), "%s", seed_backup_status_.c_str());
|
|
ImGui::Spacing();
|
|
}
|
|
ImGui::Separator();
|
|
if (ui::material::StyledButton(TR("seed_backup_copy"), ImVec2(120 * dp, 0))) {
|
|
copySecretToClipboard(seed_backup_phrase_);
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::StyledButton(TR("seed_backup_save"), ImVec2(150 * dp, 0))) {
|
|
std::string path = util::Platform::getConfigDir() + "/dragonx-seed-backup.txt";
|
|
std::string content = seed_backup_phrase_ + "\n";
|
|
const bool ok = util::Platform::writeFileAtomically(path, content,
|
|
/*restrictPermissions=*/true);
|
|
sodium_memzero(&content[0], content.size());
|
|
seed_backup_status_ = (ok ? std::string(TR("seed_backup_saved"))
|
|
: std::string(TR("seed_backup_save_failed"))) + path;
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::StyledButton(TR("seed_backup_close"), ImVec2(120 * dp, 0))) {
|
|
closeAndWipe();
|
|
ui::material::EndOverlayDialog();
|
|
return;
|
|
}
|
|
ui::material::EndOverlayDialog();
|
|
return;
|
|
} else if (!seed_backup_status_.empty()) {
|
|
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", seed_backup_status_.c_str());
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
ImGui::Separator();
|
|
if (ui::material::StyledButton(TR("seed_backup_close"), ImVec2(120 * dp, 0))) {
|
|
closeAndWipe();
|
|
}
|
|
ui::material::EndOverlayDialog();
|
|
}
|
|
|
|
void App::renderSeedMigrationDialog()
|
|
{
|
|
auto wipeSeed = [this]() {
|
|
if (!seed_migration_seed_.empty())
|
|
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
|
|
seed_migration_seed_.clear();
|
|
};
|
|
auto close = [this, &wipeSeed]() { wipeSeed(); show_seed_migration_ = false; };
|
|
|
|
// A background op (create/sweep/adopt) can't be cancelled cleanly, so the modal must not be
|
|
// dismissed mid-op.
|
|
const bool busy = seed_migration_in_flight_
|
|
|| seed_migration_step_ == SeedMigrationStep::Sweeping
|
|
|| seed_migration_step_ == SeedMigrationStep::Adopting;
|
|
|
|
if (!ui::material::BeginOverlayDialog(TR("mig_title"), &show_seed_migration_, 580.0f, 0.94f)) {
|
|
if (!busy) wipeSeed();
|
|
return;
|
|
}
|
|
// BeginOverlayDialog flips show_seed_migration_ to false on an Esc / outside-click / X dismiss
|
|
// (but still returns true this frame). Veto the dismiss while busy; otherwise it's handled by
|
|
// the post-EndOverlayDialog wipe below.
|
|
if (!show_seed_migration_ && busy) show_seed_migration_ = true;
|
|
|
|
const ImVec4 kMedium = ImGui::ColorConvertU32ToFloat4(ui::material::OnSurfaceMedium());
|
|
// BeginOverlayDialog scales the card by dpiScale(); the button widths below are authored in
|
|
// logical px and must be scaled the same way or they render small/left-packed at higher DPI.
|
|
const float dp = ui::Layout::dpiScale();
|
|
|
|
switch (seed_migration_step_) {
|
|
case SeedMigrationStep::Intro: {
|
|
// Pre-flight needs a connected, unlocked wallet — gate on that, then branch on what the
|
|
// probe found so we never offer a pointless (already-seeded) or impossible (old daemon) run.
|
|
if (!isConnected()) {
|
|
ImGui::TextWrapped("%s", TR("mig_not_connected"));
|
|
ImGui::Spacing(); ImGui::Separator();
|
|
if (ui::material::StyledButton(TR("close"), ImVec2(120 * dp, 0))) close();
|
|
break;
|
|
}
|
|
if (state_.isLocked()) {
|
|
ImGui::TextWrapped("%s", TR("mig_unlock_to_migrate"));
|
|
ImGui::Spacing(); ImGui::Separator();
|
|
if (ui::material::StyledButton(TR("close"), ImVec2(120 * dp, 0))) close();
|
|
break;
|
|
}
|
|
if (!seed_migration_precheck_started_ && !capture_mode_) beginSeedMigrationPrecheck();
|
|
|
|
switch (seed_migration_precheck_) {
|
|
case SeedMigrationPrecheck::Pending:
|
|
ImGui::TextColored(kMedium, "%s%s", TR("mig_precheck_checking"), ui::material::LoadingDots());
|
|
break;
|
|
case SeedMigrationPrecheck::AlreadyMnemonic:
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::SuccessVec4());
|
|
ImGui::TextWrapped("%s", TR("mig_already_mnemonic"));
|
|
ImGui::PopStyleColor();
|
|
ImGui::Spacing(); ImGui::Separator();
|
|
if (ui::material::StyledButton(TR("mig_backup_instead"), ImVec2(200 * dp, 0))) {
|
|
close();
|
|
showSeedBackupDialog();
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::StyledButton(TR("close"), ImVec2(120 * dp, 0))) close();
|
|
break;
|
|
case SeedMigrationPrecheck::DaemonTooOld:
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
|
ImGui::TextWrapped("%s", TR("mig_daemon_too_old"));
|
|
ImGui::PopStyleColor();
|
|
ImGui::Spacing(); ImGui::Separator();
|
|
if (ui::material::StyledButton(TR("close"), ImVec2(120 * dp, 0))) close();
|
|
break;
|
|
case SeedMigrationPrecheck::CheckFailed:
|
|
ImGui::TextWrapped("%s", TR("mig_check_failed"));
|
|
ImGui::Spacing(); ImGui::Separator();
|
|
if (ui::material::StyledButton(TR("mig_recheck"), ImVec2(120 * dp, 0)))
|
|
seed_migration_precheck_started_ = false; // re-probe next frame
|
|
ImGui::SameLine();
|
|
if (ui::material::StyledButton(TR("close"), ImVec2(120 * dp, 0))) close();
|
|
break;
|
|
case SeedMigrationPrecheck::Legacy:
|
|
default:
|
|
ImGui::TextWrapped("%s", TR("mig_intro"));
|
|
ImGui::Spacing(); ImGui::Separator();
|
|
if (ui::material::StyledButton(TR("mig_create"), ImVec2(180 * dp, 0)))
|
|
beginCreateSeedWallet();
|
|
ImGui::SameLine();
|
|
if (ui::material::StyledButton(TR("cancel"), ImVec2(120 * dp, 0)))
|
|
close();
|
|
break;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case SeedMigrationStep::Working:
|
|
ImGui::TextWrapped("%s", TR("mig_working"));
|
|
ImGui::Spacing();
|
|
ImGui::TextColored(kMedium, "%s%s", seed_migration_status_.c_str(), ui::material::LoadingDots());
|
|
break;
|
|
|
|
case SeedMigrationStep::ShowSeed: {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
|
ImGui::TextWrapped("%s", TR("mig_seed_warning"));
|
|
ImGui::PopStyleColor();
|
|
ImGui::Spacing();
|
|
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_migration_seed_));
|
|
ImGui::Spacing();
|
|
ImGui::TextColored(kMedium, "%s", TR("mig_receive_addr"));
|
|
ImGui::TextWrapped("%s", seed_migration_dest_.c_str());
|
|
ImGui::Spacing();
|
|
if (ui::material::StyledButton(TR("mig_copy_seed"), ImVec2(120 * dp, 0)))
|
|
copySecretToClipboard(seed_migration_seed_);
|
|
ImGui::SameLine();
|
|
if (ui::material::StyledButton(TR("seed_backup_save"), ImVec2(130 * dp, 0))) {
|
|
std::string path = util::Platform::getConfigDir() + "/dragonx-seed-wallet-backup.txt";
|
|
std::string content = seed_migration_seed_ + "\nAddress: " + seed_migration_dest_ + "\n";
|
|
const bool ok = util::Platform::writeFileAtomically(path, content, /*restrict=*/true);
|
|
sodium_memzero(&content[0], content.size());
|
|
seed_migration_status_ = std::string(ok ? TR("seed_backup_saved") : TR("seed_backup_save_failed")) + path;
|
|
}
|
|
if (!seed_migration_status_.empty()) {
|
|
ImGui::TextColored(ui::material::SuccessVec4(), "%s", seed_migration_status_.c_str());
|
|
}
|
|
ImGui::Spacing();
|
|
ImGui::Checkbox(TR("mig_backed_up"), &seed_migration_backed_up_);
|
|
ImGui::Spacing();
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
|
|
ImGui::TextWrapped("%s", TR("mig_step1_done"));
|
|
ImGui::PopStyleColor();
|
|
ImGui::Separator();
|
|
if (!seed_migration_backed_up_) ImGui::BeginDisabled();
|
|
if (ui::material::StyledButton(TR("mig_continue_sweep"), ImVec2(170 * dp, 0))) {
|
|
wipeSeed(); // the sweep/adopt steps only use the address, never the seed itself
|
|
seed_migration_step_ = SeedMigrationStep::Sweep;
|
|
seed_migration_balance_loaded_ = false;
|
|
seed_migration_nofunds_confirmed_ = false;
|
|
refreshSeedMigrationBalance();
|
|
}
|
|
if (!seed_migration_backed_up_) ImGui::EndDisabled();
|
|
ImGui::SameLine();
|
|
if (ui::material::StyledButton(TR("mig_discard"), ImVec2(160 * dp, 0))) {
|
|
// Throw away the isolated new wallet + clear the pending state.
|
|
if (!seed_migration_temp_dir_.empty()) {
|
|
std::error_code ec;
|
|
std::filesystem::remove_all(seed_migration_temp_dir_, ec);
|
|
}
|
|
if (settings_) {
|
|
settings_->setSeedMigrationPending(false);
|
|
settings_->setSeedMigrationDest("");
|
|
settings_->setSeedMigrationTempDir("");
|
|
settings_->save();
|
|
}
|
|
seed_migration_temp_dir_.clear();
|
|
seed_migration_dest_.clear();
|
|
close();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case SeedMigrationStep::Sweep: {
|
|
// Wait for the balance to load before deciding funds vs no-funds (0.0 is also the initial
|
|
// value, so gate on the loaded flag to avoid flashing "no funds" while it's still fetching).
|
|
if (!seed_migration_balance_loaded_) {
|
|
ImGui::TextColored(kMedium, "%s%s", TR("mig_checking_balance"), ui::material::LoadingDots());
|
|
ImGui::Spacing(); ImGui::Separator();
|
|
if (ui::material::StyledButton(TR("mig_later"), ImVec2(100 * dp, 0))) close();
|
|
break;
|
|
}
|
|
if (seed_migration_balance_ <= DRAGONX_DEFAULT_FEE) {
|
|
// Nothing to sweep — adopt the fresh seed wallet directly. Its wallet.dat replaces the
|
|
// current (empty) one; adopt still moves the legacy wallet aside to a timestamped backup.
|
|
ImGui::TextWrapped("%s", TR("mig_no_funds"));
|
|
ImGui::Spacing();
|
|
ImGui::TextWrapped("%s%s", TR("mig_to"), seed_migration_dest_.c_str());
|
|
ImGui::Spacing();
|
|
// Adopt swaps wallet.dat — gate it behind an explicit acknowledgement even though no
|
|
// funds are at risk (the legacy wallet is still moved aside to a backup on adopt).
|
|
ImGui::Checkbox(TR("mig_nofunds_confirm"), &seed_migration_nofunds_confirmed_);
|
|
ImGui::Spacing(); ImGui::Separator();
|
|
if (!seed_migration_nofunds_confirmed_) ImGui::BeginDisabled();
|
|
if (ui::material::StyledButton(TR("mig_adopt_now"), ImVec2(190 * dp, 0)))
|
|
beginAdoptSeedWallet();
|
|
if (!seed_migration_nofunds_confirmed_) ImGui::EndDisabled();
|
|
ImGui::SameLine();
|
|
if (ui::material::StyledButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
|
refreshSeedMigrationBalance();
|
|
ImGui::SameLine();
|
|
if (ui::material::StyledButton(TR("mig_later"), ImVec2(100 * dp, 0)))
|
|
close();
|
|
break;
|
|
}
|
|
ImGui::TextWrapped("%s", TR("mig_sweep_intro"));
|
|
ImGui::Spacing();
|
|
char balbuf[96]; snprintf(balbuf, sizeof(balbuf), TR("mig_balance"), seed_migration_balance_);
|
|
ImGui::TextColored(kMedium, "%s", balbuf);
|
|
ImGui::TextWrapped("%s%s", TR("mig_to"), seed_migration_dest_.c_str());
|
|
if (state_.isLocked()) {
|
|
ImGui::Spacing();
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
|
ImGui::TextWrapped("%s", TR("mig_unlock_first"));
|
|
ImGui::PopStyleColor();
|
|
}
|
|
ImGui::Spacing();
|
|
ImGui::Separator();
|
|
{
|
|
const bool canSweep = !state_.isLocked() && seed_migration_balance_ > 0.0;
|
|
if (!canSweep) ImGui::BeginDisabled();
|
|
if (ui::material::StyledButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0)))
|
|
beginSweepToSeedWallet();
|
|
if (!canSweep) ImGui::EndDisabled();
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::StyledButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
|
refreshSeedMigrationBalance();
|
|
ImGui::SameLine();
|
|
if (ui::material::StyledButton(TR("mig_later"), ImVec2(100 * dp, 0)))
|
|
close(); // pending state persists; resumes here next time
|
|
break;
|
|
}
|
|
|
|
case SeedMigrationStep::Sweeping:
|
|
ImGui::TextWrapped("%s", TR("mig_sweeping"));
|
|
ImGui::Spacing();
|
|
ImGui::TextColored(kMedium, "%s%s", seed_migration_status_.c_str(), ui::material::LoadingDots());
|
|
break;
|
|
|
|
case SeedMigrationStep::Confirming: {
|
|
const double dust = DRAGONX_DEFAULT_FEE; // treat <= one fee as "empty"
|
|
const bool confirmed = seed_migration_sweep_confs_ >= 1;
|
|
const bool legacyEmpty = seed_migration_legacy_remaining_ >= 0.0 &&
|
|
seed_migration_legacy_remaining_ <= dust;
|
|
ImGui::TextWrapped("%s", TR("mig_confirming"));
|
|
ImGui::Spacing();
|
|
char cbuf[64]; snprintf(cbuf, sizeof(cbuf), TR("mig_confs"), seed_migration_sweep_confs_);
|
|
ImGui::TextColored(kMedium, "%s", cbuf);
|
|
if (!seed_migration_sweep_txid_.empty())
|
|
ImGui::TextColored(kMedium, "%s%s", TR("mig_txid"), seed_migration_sweep_txid_.c_str());
|
|
if (seed_migration_legacy_remaining_ >= 0.0) {
|
|
char rbuf[96]; snprintf(rbuf, sizeof(rbuf), TR("mig_remaining"), seed_migration_legacy_remaining_);
|
|
ImGui::TextColored(kMedium, "%s", rbuf);
|
|
}
|
|
ImGui::Spacing();
|
|
ImGui::Separator();
|
|
if (confirmed && legacyEmpty) {
|
|
if (ui::material::StyledButton(TR("mig_adopt"), ImVec2(180 * dp, 0)))
|
|
beginAdoptSeedWallet();
|
|
} else if (confirmed && !legacyEmpty) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
|
ImGui::TextWrapped("%s", TR("mig_remainder"));
|
|
ImGui::PopStyleColor();
|
|
if (ui::material::StyledButton(TR("mig_sweep_remaining"), ImVec2(180 * dp, 0)))
|
|
beginSweepToSeedWallet();
|
|
} else {
|
|
ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine"));
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::StyledButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
|
pollSweepStatus();
|
|
ImGui::SameLine();
|
|
if (ui::material::StyledButton(TR("mig_later"), ImVec2(100 * dp, 0)))
|
|
close(); // pending state + txid persist; resumes here next time
|
|
break;
|
|
}
|
|
|
|
case SeedMigrationStep::Adopting:
|
|
ImGui::TextWrapped("%s", TR("mig_adopting"));
|
|
ImGui::Spacing();
|
|
ImGui::TextColored(kMedium, "%s%s", seed_migration_status_.c_str(), ui::material::LoadingDots());
|
|
ImGui::Spacing();
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
|
|
ImGui::TextWrapped("%s", TR("mig_adopting_note"));
|
|
ImGui::PopStyleColor();
|
|
break;
|
|
|
|
case SeedMigrationStep::Done:
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::SuccessVec4());
|
|
ImGui::TextWrapped("%s", TR("mig_done"));
|
|
ImGui::PopStyleColor();
|
|
ImGui::Spacing();
|
|
ImGui::TextWrapped("%s", TR("mig_done_detail"));
|
|
if (!seed_migration_status_.empty()) { // a non-fatal warning (e.g. the daemon didn't restart)
|
|
ImGui::Spacing();
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
|
ImGui::TextWrapped("%s", seed_migration_status_.c_str());
|
|
ImGui::PopStyleColor();
|
|
}
|
|
ImGui::Spacing();
|
|
ImGui::Separator();
|
|
if (ui::material::StyledButton(TR("mig_done_btn"), ImVec2(120 * dp, 0)))
|
|
close();
|
|
break;
|
|
|
|
case SeedMigrationStep::Error:
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
|
ImGui::TextWrapped("%s", seed_migration_status_.c_str());
|
|
ImGui::PopStyleColor();
|
|
ImGui::Spacing();
|
|
ImGui::Separator();
|
|
if (ui::material::StyledButton(TR("close"), ImVec2(120 * dp, 0)))
|
|
close();
|
|
break;
|
|
}
|
|
|
|
ui::material::EndOverlayDialog();
|
|
// A real dismiss (Esc/outside/X) or a button close() leaves the flag false — scrub the seed.
|
|
if (!show_seed_migration_) wipeSeed();
|
|
}
|
|
|
|
void App::renderAntivirusHelpDialog()
|
|
{
|
|
#ifdef _WIN32
|
|
if (!pending_antivirus_dialog_) return;
|
|
|
|
if (!ui::material::BeginOverlayDialog("Windows Defender Blocked Miner", &pending_antivirus_dialog_, 560.0f, 0.94f)) {
|
|
return;
|
|
}
|
|
|
|
ImGui::TextWrapped(
|
|
"Mining software is often flagged as potentially unwanted. "
|
|
"Follow these steps to enable pool mining:");
|
|
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
ui::material::Type().text(ui::material::TypeStyle::Subtitle2, "Step 1: Add Exclusion");
|
|
ImGui::BulletText("Open Windows Security > Virus & threat protection");
|
|
ImGui::BulletText("Click Manage settings > Exclusions > Add or remove");
|
|
ImGui::BulletText("Add folder: %%APPDATA%%\\ObsidianDragon\\");
|
|
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
ui::material::Type().text(ui::material::TypeStyle::Subtitle2, "Step 2: Restore from Quarantine (if needed)");
|
|
ImGui::BulletText("Windows Security > Protection history");
|
|
ImGui::BulletText("Find xmrig.exe and click Restore");
|
|
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
ui::material::Type().text(ui::material::TypeStyle::Subtitle2, "Step 3: Restart wallet and try again");
|
|
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
|
ImGui::Separator();
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
|
|
float btnW = 160.0f;
|
|
if (ui::material::StyledButton("Open Windows Security", ImVec2(btnW, 0))) {
|
|
// Open Windows Security app to the exclusions page
|
|
ShellExecuteA(NULL, "open", "windowsdefender://threat", NULL, NULL, SW_SHOWNORMAL);
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::StyledButton("Close", ImVec2(100.0f, 0))) {
|
|
pending_antivirus_dialog_ = false;
|
|
}
|
|
|
|
ui::material::EndOverlayDialog();
|
|
#endif
|
|
}
|
|
|
|
void App::refreshNow()
|
|
{
|
|
// Trigger immediate refresh on all categories
|
|
network_refresh_.markImmediateRefresh();
|
|
transactions_dirty_ = true; // Force transaction list update
|
|
addresses_dirty_ = true; // Force address/balance update
|
|
last_tx_block_height_ = -1; // Reset tx cache
|
|
invalidateShieldedHistoryScanProgress(true);
|
|
}
|
|
|
|
void App::handlePaymentURI(const std::string& uri)
|
|
{
|
|
auto payment = util::parsePaymentURI(uri);
|
|
|
|
if (!payment.valid) {
|
|
ui::Notifications::instance().error("Invalid payment URI: " + payment.error);
|
|
return;
|
|
}
|
|
|
|
// Store pending payment
|
|
pending_payment_valid_ = true;
|
|
pending_to_address_ = payment.address;
|
|
pending_amount_ = payment.amount;
|
|
pending_memo_ = payment.memo;
|
|
pending_label_ = payment.label;
|
|
|
|
// Switch to Send page
|
|
setCurrentPage(ui::NavPage::Send);
|
|
|
|
// Notify user
|
|
std::string msg = "Payment request loaded";
|
|
if (payment.amount > 0) {
|
|
char buf[64];
|
|
snprintf(buf, sizeof(buf), " for %.8f DRGX", payment.amount);
|
|
msg += buf;
|
|
}
|
|
ui::Notifications::instance().info(msg);
|
|
}
|
|
|
|
void App::setCurrentTab(int tab) {
|
|
// Legacy int-to-NavPage mapping (used by balance_tab context menus)
|
|
static const ui::NavPage kTabMap[] = {
|
|
ui::NavPage::Overview, // 0 = Balance
|
|
ui::NavPage::Send, // 1 = Send
|
|
ui::NavPage::Receive, // 2 = Receive
|
|
ui::NavPage::History, // 3 = Transactions
|
|
ui::NavPage::Mining, // 4 = Mining
|
|
ui::NavPage::Peers, // 5 = Peers
|
|
ui::NavPage::Market, // 6 = Market
|
|
ui::NavPage::Console, // 7 = Console
|
|
ui::NavPage::Explorer, // 8 = Explorer
|
|
ui::NavPage::Settings, // 9 = Settings
|
|
};
|
|
if (tab >= 0 && tab < static_cast<int>(sizeof(kTabMap)/sizeof(kTabMap[0])))
|
|
setCurrentPage(kTabMap[tab]);
|
|
}
|
|
|
|
bool App::startEmbeddedDaemon()
|
|
{
|
|
if (!supportsEmbeddedDaemon()) {
|
|
DEBUG_LOGF("Embedded daemon support unavailable in this build, not starting\n");
|
|
return false;
|
|
}
|
|
|
|
if (!isUsingEmbeddedDaemon()) {
|
|
DEBUG_LOGF("Embedded daemon disabled, not starting\n");
|
|
return false;
|
|
}
|
|
|
|
// Check if Sapling params exist - try extracting if embedded
|
|
if (!rpc::Connection::verifySaplingParams()) {
|
|
DEBUG_LOGF("Sapling params not found, checking for embedded resources...\n");
|
|
|
|
// Try to extract embedded resources if available
|
|
if (resources::hasEmbeddedResources()) {
|
|
DEBUG_LOGF("Extracting embedded Sapling params...\n");
|
|
daemon_status_ = TR("sb_extracting_sapling");
|
|
resources::extractEmbeddedResources();
|
|
|
|
// Check again after extraction
|
|
if (!rpc::Connection::verifySaplingParams()) {
|
|
daemon_status_ = TR("sb_sapling_failed");
|
|
DEBUG_LOGF("Sapling params still not found after extraction!\n");
|
|
DEBUG_LOGF("Expected location: %s\n", rpc::Connection::getSaplingParamsDir().c_str());
|
|
return false;
|
|
}
|
|
DEBUG_LOGF("Sapling params extracted successfully\n");
|
|
} else {
|
|
// Fallback: check for params bundled alongside the executable
|
|
// (zip distributions bundle sapling-*.params next to the binary)
|
|
namespace fs = std::filesystem;
|
|
std::string exe_dir = util::Platform::getExecutableDirectory();
|
|
std::string daemon_dir = resources::getDaemonDirectory();
|
|
const char* paramFiles[] = { "sapling-spend.params", "sapling-output.params", "asmap.dat" };
|
|
bool copied = false;
|
|
if (!exe_dir.empty()) {
|
|
std::error_code ec;
|
|
fs::create_directories(daemon_dir, ec);
|
|
|
|
// On macOS .app bundles, params are in Contents/Resources/
|
|
// while the executable is in Contents/MacOS/
|
|
std::vector<std::string> searchDirs = { exe_dir };
|
|
#ifdef __APPLE__
|
|
{
|
|
fs::path resourcesDir = fs::path(exe_dir).parent_path() / "Resources";
|
|
if (fs::is_directory(resourcesDir))
|
|
searchDirs.insert(searchDirs.begin(), resourcesDir.string());
|
|
}
|
|
#endif
|
|
|
|
for (const char* name : paramFiles) {
|
|
fs::path dst = fs::path(daemon_dir) / name;
|
|
if (fs::exists(dst)) continue;
|
|
for (const auto& dir : searchDirs) {
|
|
fs::path src = fs::path(dir) / name;
|
|
if (fs::exists(src)) {
|
|
DEBUG_LOGF("Copying bundled %s from %s to %s\n", name, dir.c_str(), daemon_dir.c_str());
|
|
fs::copy_file(src, dst, ec);
|
|
if (!ec) copied = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (copied && rpc::Connection::verifySaplingParams()) {
|
|
DEBUG_LOGF("Sapling params copied from exe directory successfully\n");
|
|
} else {
|
|
daemon_status_ = TR("sb_sapling_not_found");
|
|
DEBUG_LOGF("Sapling params not found and no embedded resources available!\n");
|
|
DEBUG_LOGF("Expected location: %s\n", rpc::Connection::getSaplingParamsDir().c_str());
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ensure asmap.dat and daemon binaries are copied from the bundle
|
|
// to the daemon directory even if sapling params already existed.
|
|
{
|
|
namespace fs = std::filesystem;
|
|
std::string exe_dir = util::Platform::getExecutableDirectory();
|
|
std::string daemon_dir = resources::getDaemonDirectory();
|
|
if (!exe_dir.empty()) {
|
|
std::error_code ec;
|
|
fs::create_directories(daemon_dir, ec);
|
|
|
|
std::vector<std::string> searchDirs = { exe_dir };
|
|
#ifdef __APPLE__
|
|
{
|
|
fs::path resourcesDir = fs::path(exe_dir).parent_path() / "Resources";
|
|
if (fs::is_directory(resourcesDir))
|
|
searchDirs.insert(searchDirs.begin(), resourcesDir.string());
|
|
}
|
|
#endif
|
|
const char* extraFiles[] = { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" };
|
|
for (const char* name : extraFiles) {
|
|
fs::path dst = fs::path(daemon_dir) / name;
|
|
if (fs::exists(dst)) continue;
|
|
for (const auto& dir : searchDirs) {
|
|
fs::path src = fs::path(dir) / name;
|
|
if (fs::exists(src)) {
|
|
DEBUG_LOGF("Copying bundled %s from %s to %s\n", name, dir.c_str(), daemon_dir.c_str());
|
|
fs::copy_file(src, dst, ec);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create daemon controller if needed
|
|
if (!daemon_controller_) {
|
|
daemon_controller_ = std::make_unique<daemon::DaemonController>();
|
|
|
|
// Set up state callback
|
|
daemon_controller_->setStateCallback([this](daemon::EmbeddedDaemon::State state, const std::string& msg) {
|
|
switch (state) {
|
|
case daemon::EmbeddedDaemon::State::Starting:
|
|
daemon_status_ = TR("sb_starting_daemon");
|
|
break;
|
|
case daemon::EmbeddedDaemon::State::Running:
|
|
daemon_status_ = TR("sb_dragonxd_running");
|
|
break;
|
|
case daemon::EmbeddedDaemon::State::Stopping:
|
|
daemon_status_ = TR("sb_dragonxd_stopping");
|
|
break;
|
|
case daemon::EmbeddedDaemon::State::Stopped:
|
|
daemon_status_ = TR("sb_dragonxd_stopped");
|
|
break;
|
|
case daemon::EmbeddedDaemon::State::Error:
|
|
daemon_status_ = "Error: " + msg;
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
return daemon_controller_->start(settings_.get());
|
|
}
|
|
|
|
void App::stopEmbeddedDaemon()
|
|
{
|
|
if (!daemon_controller_) return;
|
|
|
|
auto shutdownDecision = daemon_controller_->shutdownDecision(
|
|
false, settings_ && settings_->getStopExternalDaemon());
|
|
if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) {
|
|
DEBUG_LOGF("stopEmbeddedDaemon: %s — skipping\n", shutdownDecision.logReason);
|
|
return;
|
|
}
|
|
|
|
// Send RPC "stop" command — this is the graceful path that lets the
|
|
// daemon flush state, save block indexes, close sockets, etc.
|
|
bool stop_sent = false;
|
|
|
|
// Try the existing RPC connection first
|
|
if (rpc_ && rpc_->isConnected()) {
|
|
DEBUG_LOGF("Sending stop command via existing RPC connection...\n");
|
|
try {
|
|
rpc_->stop([](const json&) {
|
|
DEBUG_LOGF("Stop command acknowledged by daemon\n");
|
|
});
|
|
stop_sent = true;
|
|
} catch (...) {
|
|
DEBUG_LOGF("Failed to send stop via existing connection\n");
|
|
}
|
|
}
|
|
|
|
// If the main connection wasn't established (e.g. daemon was still
|
|
// starting up when user closed the window), create a temporary
|
|
// RPC connection just to send the stop command.
|
|
if (!stop_sent) {
|
|
DEBUG_LOGF("Main RPC not connected — creating temporary connection for stop...\n");
|
|
auto config = rpc::Connection::autoDetectConfig();
|
|
if (!config.rpcuser.empty() && !config.rpcpassword.empty()) {
|
|
auto tmp_rpc = std::make_unique<rpc::RPCClient>();
|
|
// Use a short timeout — if daemon isn't listening yet, don't block
|
|
if (tmp_rpc->connect(config.host, config.port,
|
|
config.rpcuser, config.rpcpassword,
|
|
config.use_tls)) {
|
|
DEBUG_LOGF("Temporary RPC connected, sending stop...\n");
|
|
if (sendStopCommandSafely(*tmp_rpc, "Temporary daemon stop")) {
|
|
stop_sent = true;
|
|
}
|
|
tmp_rpc->disconnect();
|
|
} else {
|
|
DEBUG_LOGF("Could not establish temporary RPC connection\n");
|
|
}
|
|
} else {
|
|
DEBUG_LOGF("No RPC credentials available (DRAGONX.conf missing?)\n");
|
|
}
|
|
}
|
|
|
|
if (stop_sent) {
|
|
DEBUG_LOGF("Waiting for daemon to flush block index and shut down...\n");
|
|
shutdown_status_ = "Waiting for daemon to flush block index...";
|
|
// Give the daemon time to flush LevelDB to disk before we
|
|
// escalate to SIGTERM. On macOS/APFS, LevelDB compaction +
|
|
// fsync can take 15-20s on a large chain. The stop() method
|
|
// will wait this long for a *natural* exit (via the RPC stop
|
|
// we already sent) before falling back to SIGTERM.
|
|
}
|
|
|
|
shutdown_status_ = "Waiting for dragonxd process to exit...";
|
|
// 20s grace period for the RPC "stop" to complete (LevelDB flush).
|
|
// Only after that does stop() escalate to SIGTERM, then SIGKILL.
|
|
daemon_controller_->stop(20000);
|
|
}
|
|
|
|
bool App::isEmbeddedDaemonRunning() const
|
|
{
|
|
return daemon_controller_ && daemon_controller_->isRunning();
|
|
}
|
|
|
|
void App::rescanBlockchain()
|
|
{
|
|
if (!supportsFullNodeLifecycleActions()) {
|
|
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
|
|
return;
|
|
}
|
|
|
|
auto decision = daemon::DaemonController::evaluateLifecycleOperation(
|
|
daemon::DaemonController::LifecycleOperation::Rescan,
|
|
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning());
|
|
if (!decision.allowed) {
|
|
ui::Notifications::instance().warning(decision.warning);
|
|
return;
|
|
}
|
|
|
|
// Re-entrancy guard: a rescan/repair (both drive state_.sync.rescanning) or this exact task already
|
|
// running would stomp each other — a second confirm must not launch a duplicate operation.
|
|
if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) {
|
|
ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
|
|
return;
|
|
}
|
|
|
|
DEBUG_LOGF("[App] Starting blockchain rescan - stopping daemon first\n");
|
|
ui::Notifications::instance().info("Restarting daemon with -rescan flag...");
|
|
|
|
// Initialize rescan state for status bar display. rescan_confirmed_active_ stays false until we
|
|
// actually observe the restarted daemon rescanning — so the first poll (which may still reach the
|
|
// pre-restart daemon and see rescanning=false) can't be misread as instant completion.
|
|
state_.sync.rescanning = true;
|
|
rescan_confirmed_active_ = false;
|
|
state_.sync.rescan_progress = 0.0f;
|
|
state_.sync.rescan_status = decision.status;
|
|
transactions_dirty_ = true;
|
|
last_tx_block_height_ = -1;
|
|
invalidateShieldedHistoryScanProgress(true);
|
|
|
|
// Set rescan flag BEFORE stopping so it's ready when we restart
|
|
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
|
DEBUG_LOGF("[App] Rescan flag set, rescanOnNextStart=%d\n", daemon_controller_->rescanOnNextStart() ? 1 : 0);
|
|
|
|
// Stop daemon, then restart
|
|
async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) {
|
|
DEBUG_LOGF("[App] Stopping daemon for rescan...\n");
|
|
AppDaemonLifecycleRuntime runtime(*this);
|
|
daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
|
|
daemon_controller_->executeLifecycleOperation(decision, runtime, context);
|
|
});
|
|
}
|
|
|
|
void App::repairWallet()
|
|
{
|
|
if (!supportsFullNodeLifecycleActions()) {
|
|
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
|
|
return;
|
|
}
|
|
|
|
auto decision = daemon::DaemonController::evaluateLifecycleOperation(
|
|
daemon::DaemonController::LifecycleOperation::RepairWallet,
|
|
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning());
|
|
if (!decision.allowed) {
|
|
ui::Notifications::instance().warning(decision.warning);
|
|
return;
|
|
}
|
|
|
|
if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) {
|
|
ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
|
|
return;
|
|
}
|
|
|
|
DEBUG_LOGF("[App] Starting wallet repair (-zapwallettxes=2) - stopping daemon first\n");
|
|
ui::Notifications::instance().info("Restarting daemon with -zapwallettxes=2 (wallet repair)...");
|
|
|
|
// -zapwallettxes=2 deletes and rebuilds every wallet tx/note record, then rescans the whole
|
|
// chain — so reuse the rescan status UI (status bar + warmup-end completion detection). Same
|
|
// confirmed-active gating as rescan: the first poll may still reach the pre-restart daemon.
|
|
state_.sync.rescanning = true;
|
|
rescan_confirmed_active_ = false;
|
|
state_.sync.rescan_progress = 0.0f;
|
|
state_.sync.rescan_status = decision.status;
|
|
transactions_dirty_ = true;
|
|
last_tx_block_height_ = -1;
|
|
invalidateShieldedHistoryScanProgress(true);
|
|
|
|
// Set the zap flag BEFORE stopping so it's ready when we restart
|
|
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
|
DEBUG_LOGF("[App] Wallet-repair flag set, zapOnNextStart=%d\n", daemon_controller_->zapOnNextStart() ? 1 : 0);
|
|
|
|
async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) {
|
|
DEBUG_LOGF("[App] Stopping daemon for wallet repair...\n");
|
|
AppDaemonLifecycleRuntime runtime(*this);
|
|
daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
|
|
daemon_controller_->executeLifecycleOperation(decision, runtime, context);
|
|
});
|
|
}
|
|
|
|
void App::reinstallBundledDaemon()
|
|
{
|
|
if (!supportsFullNodeLifecycleActions()) {
|
|
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
|
|
return;
|
|
}
|
|
if (!resources::getBundledDaemonInfo().available) {
|
|
ui::Notifications::instance().warning("This build has no bundled daemon to install");
|
|
return;
|
|
}
|
|
// Require embedded-daemon *support*, but NOT an active daemon_controller_. When the wallet is
|
|
// attached to an external or leftover dragonxd (controller null, so stopEmbeddedDaemon() no-ops)
|
|
// we can still RPC-stop that node, overwrite the binaries, and bring up our own managed daemon —
|
|
// which is exactly the state that previously blocked "Install bundled" with a cryptic warning.
|
|
if (!supportsEmbeddedDaemon()) {
|
|
ui::Notifications::instance().warning("This build has no embedded daemon to install");
|
|
return;
|
|
}
|
|
|
|
if (async_tasks_.isRunning("reinstall-daemon")) {
|
|
ui::Notifications::instance().warning("The daemon reinstall is already in progress.");
|
|
return;
|
|
}
|
|
|
|
DEBUG_LOGF("[App] Reinstalling bundled daemon binary — stopping daemon first\n");
|
|
ui::Notifications::instance().info("Installing bundled daemon — the node will stop, update, and restart...");
|
|
|
|
async_tasks_.submit("reinstall-daemon", [this](const util::AsyncTaskManager::Token& token) {
|
|
AppDaemonLifecycleRuntime runtime(*this);
|
|
daemon::AsyncLifecycleTaskContext ctx(token, shutting_down_);
|
|
// Stop the running node so its binary is free. stopDaemonWithPolicy() covers a wallet-managed
|
|
// daemon; if we're instead attached to an external/leftover dragonxd (daemon_controller_ is
|
|
// null, so stopEmbeddedDaemon() returns immediately), send an explicit RPC stop so that node
|
|
// exits too — otherwise it keeps the RPC port and the fresh daemon can't start.
|
|
if (!daemon_controller_ && rpc_ && rpc_->isConnected()) {
|
|
sendStopCommandSafely(*rpc_, "reinstall-daemon");
|
|
}
|
|
runtime.stopDaemonWithPolicy();
|
|
// Wait (bounded, ~12s) for the process to exit and release the binary before overwriting.
|
|
for (int i = 0; i < 120 && daemon::EmbeddedDaemon::isRpcPortInUse()
|
|
&& !ctx.cancelled() && !ctx.shuttingDown(); ++i) {
|
|
ctx.sleepForMs(100);
|
|
}
|
|
// Overwrite the installed dragonx binaries (dragonxd/cli/tx) with the bundled ones.
|
|
const bool ok = resources::reextractBundledDaemon();
|
|
DEBUG_LOGF("[App] Reinstall bundled daemon: %s\n", ok ? "ok" : "FAILED");
|
|
// Restart on the freshly-installed binary.
|
|
runtime.resetOutputOffset();
|
|
if (!ctx.cancelled() && !ctx.shuttingDown()) {
|
|
runtime.startDaemon();
|
|
}
|
|
});
|
|
}
|
|
|
|
void App::deleteBlockchainData()
|
|
{
|
|
if (!supportsFullNodeLifecycleActions()) {
|
|
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
|
|
return;
|
|
}
|
|
|
|
auto decision = daemon::DaemonController::evaluateLifecycleOperation(
|
|
daemon::DaemonController::LifecycleOperation::DeleteBlockchainData,
|
|
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning());
|
|
if (!decision.allowed) {
|
|
ui::Notifications::instance().warning(decision.warning);
|
|
return;
|
|
}
|
|
|
|
if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) {
|
|
ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
|
|
return;
|
|
}
|
|
|
|
DEBUG_LOGF("[App] Deleting blockchain data - stopping daemon first\n");
|
|
ui::Notifications::instance().info("Stopping daemon and deleting blockchain data...");
|
|
|
|
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
|
async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) {
|
|
DEBUG_LOGF("[App] Stopping daemon for blockchain deletion...\n");
|
|
AppDaemonLifecycleRuntime runtime(*this);
|
|
daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
|
|
auto result = daemon_controller_->executeLifecycleOperation(decision, runtime, context);
|
|
DEBUG_LOGF("[App] Blockchain data deleted (%d items removed), restarting daemon...\n", result.deletedItems);
|
|
// Hand the count to the main thread for a completion toast (Notifications isn't thread-safe).
|
|
pending_delete_result_.store(result.deletedItems, std::memory_order_relaxed);
|
|
});
|
|
}
|
|
|
|
bool App::stopDaemonForBootstrap()
|
|
{
|
|
if (!supportsFullNodeLifecycleActions()) {
|
|
return false;
|
|
}
|
|
|
|
auto decision = daemon::DaemonController::evaluateLifecycleOperation(
|
|
daemon::DaemonController::LifecycleOperation::BootstrapStop,
|
|
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning());
|
|
if (decision.wasRunning) {
|
|
DEBUG_LOGF("[App] Stopping embedded daemon for bootstrap download...\n");
|
|
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
|
AppDaemonLifecycleRuntime runtime(*this);
|
|
daemon::ImmediateLifecycleTaskContext context;
|
|
daemon_controller_->executeLifecycleOperation(decision, runtime, context);
|
|
}
|
|
return decision.wasRunning;
|
|
}
|
|
|
|
double App::getDaemonMemoryUsageMB() const
|
|
{
|
|
// If we have an embedded daemon with a tracked process handle, use it
|
|
// directly — more reliable than a process scan since we own the handle.
|
|
if (daemon_controller_ && daemon_controller_->isRunning()) {
|
|
double mb = daemon_controller_->memoryUsageMB();
|
|
daemon_mem_diag_ = "embedded";
|
|
if (mb > 0.0) return mb;
|
|
} else {
|
|
daemon_mem_diag_ = "process scan";
|
|
}
|
|
// Fall back to platform-level process scan (external daemon)
|
|
return util::Platform::getDaemonMemoryUsageMB();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Shutdown
|
|
// ============================================================================
|
|
|
|
void App::requestQuit()
|
|
{
|
|
beginShutdown();
|
|
}
|
|
|
|
void App::beginShutdown()
|
|
{
|
|
// Only start shutdown once
|
|
if (shutting_down_) return;
|
|
shutting_down_ = true;
|
|
quit_requested_ = true;
|
|
shutdown_timer_ = 0.0f;
|
|
shutdown_start_time_ = std::chrono::steady_clock::now();
|
|
async_tasks_.cancelAll();
|
|
// The seed-migration adopt task drives daemon stop/start and swaps wallet.dat; let it finish
|
|
// before shutdown touches the daemon, so the two never race. shutting_down_ is already set, so
|
|
// the adopt task will NOT restart the daemon (it checks shutting_down_ before startEmbeddedDaemon).
|
|
if (async_tasks_.isRunning("Adopt seed wallet"))
|
|
async_tasks_.join("Adopt seed wallet");
|
|
|
|
// Signal the RPC worker to stop accepting new tasks (non-blocking), and abort any call
|
|
// already in flight so the later join() doesn't wait out a request timeout.
|
|
// The actual thread join + rpc disconnect happen in shutdown() after
|
|
// the render loop exits, so the UI stays responsive.
|
|
if (rpc_) rpc_->requestAbort();
|
|
if (fast_rpc_) fast_rpc_->requestAbort();
|
|
if (worker_) {
|
|
worker_->requestStop();
|
|
}
|
|
if (fast_worker_) {
|
|
fast_worker_->requestStop();
|
|
}
|
|
|
|
// Stop xmrig pool miner before stopping the daemon
|
|
if (xmrig_manager_ && xmrig_manager_->isRunning()) {
|
|
shutdown_status_ = "Stopping pool miner...";
|
|
xmrig_manager_->stop(3000);
|
|
}
|
|
|
|
DEBUG_LOGF("beginShutdown: starting (daemon_controller_=%s)\n",
|
|
daemon_controller_ ? "yes" : "no");
|
|
|
|
// If no embedded daemon, just mark done — don't stop
|
|
// an externally-managed daemon that the user started themselves.
|
|
// Worker join + RPC disconnect happen in shutdown().
|
|
if (!daemon_controller_) {
|
|
DEBUG_LOGF("beginShutdown: no embedded daemon, disconnecting only\n");
|
|
shutdown_status_ = "Disconnecting...";
|
|
if (settings_) {
|
|
settings_->save();
|
|
}
|
|
shutdown_complete_ = true;
|
|
return;
|
|
}
|
|
|
|
// Save settings now (safe to do on main thread)
|
|
if (settings_) {
|
|
settings_->save();
|
|
}
|
|
|
|
auto shutdownDecision = daemon_controller_->shutdownDecision(
|
|
settings_ && settings_->getKeepDaemonRunning(),
|
|
settings_ && settings_->getStopExternalDaemon());
|
|
if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) {
|
|
DEBUG_LOGF("beginShutdown: %s, skipping daemon stop\n", shutdownDecision.logReason);
|
|
shutdown_status_ = shutdownDecision.status;
|
|
shutdown_complete_ = true;
|
|
return;
|
|
}
|
|
|
|
shutdown_status_ = shutdownDecision.status;
|
|
DEBUG_LOGF("beginShutdown: spawning shutdown thread for daemon stop\n");
|
|
|
|
// Run the daemon shutdown on a background thread so the UI
|
|
// keeps rendering the shutdown screen (like SilentDragonX's
|
|
// modal "Please wait" dialog).
|
|
shutdown_thread_ = std::thread([this]() {
|
|
DEBUG_LOGF("shutdown thread: calling stopEmbeddedDaemon()\n");
|
|
shutdown_status_ = "Sending stop command to daemon...";
|
|
// Send RPC stop command
|
|
stopEmbeddedDaemon();
|
|
|
|
DEBUG_LOGF("shutdown thread: daemon stopped, disconnecting RPC\n");
|
|
shutdown_status_ = "Cleaning up...";
|
|
|
|
DEBUG_LOGF("shutdown thread: complete\n");
|
|
shutdown_status_ = "Shutdown complete";
|
|
shutdown_complete_ = true;
|
|
});
|
|
}
|
|
|
|
void App::renderShutdownScreen()
|
|
{
|
|
using namespace ui::material;
|
|
auto shutElem = [](const char* key, float fb) {
|
|
float v = ui::schema::UI().drawElement("components.shutdown", key).size;
|
|
return v >= 0 ? v : fb;
|
|
};
|
|
shutdown_timer_ += ImGui::GetIO().DeltaTime;
|
|
|
|
// Track how long the status text has been unchanged. A normal shutdown keeps updating the status
|
|
// ("Stopping pool miner…" -> "Flushing…" -> "Cleaning up…"); a genuine hang stalls it. Offer Force
|
|
// Quit only once the status has STALLED (likely hung), not on a bare timer, with a hard ceiling so
|
|
// it's never impossible to escape.
|
|
static std::string s_lastShutStatus;
|
|
static float s_shutStallTimer = 0.0f;
|
|
if (shutdown_status_ != s_lastShutStatus) { s_lastShutStatus = shutdown_status_; s_shutStallTimer = 0.0f; }
|
|
else s_shutStallTimer += ImGui::GetIO().DeltaTime;
|
|
const bool shutdownStalled = s_shutStallTimer >= 8.0f;
|
|
const bool allowForceQuit = shutdownStalled || shutdown_timer_ >= 20.0f;
|
|
|
|
// Use the main viewport so the overlay covers the primary window
|
|
ImGuiViewport* vp = ImGui::GetMainViewport();
|
|
ImVec2 vp_pos = vp->Pos;
|
|
ImVec2 vp_size = vp->Size;
|
|
float cx = vp_size.x * 0.5f; // horizontal centre (local coords)
|
|
|
|
// Semi-transparent dark overlay covering the entire main window
|
|
ImGui::SetNextWindowPos(vp_pos);
|
|
ImGui::SetNextWindowSize(vp_size);
|
|
ImGui::SetNextWindowFocus();
|
|
// Shared overlay-scrim tone (matches dialog backdrops); the shutdown screen stays an opaque
|
|
// scrim (no blur) so it reads as "closing", not a modal over your work.
|
|
ImGui::PushStyleColor(ImGuiCol_WindowBg, OverlayScrimColor(0.92f));
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f);
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
|
|
ImGuiWindowFlags shutdownFlags =
|
|
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
|
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
|
|
ImGuiWindowFlags_NoSavedSettings;
|
|
// Allow input only once Force Quit is offered (status stalled / hard ceiling), so the screen stays
|
|
// click-through while a normal shutdown is progressing.
|
|
if (!allowForceQuit)
|
|
shutdownFlags |= ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav;
|
|
ImGui::Begin("##ShutdownOverlay", nullptr, shutdownFlags);
|
|
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
// Convert local centre to screen coords for draw-list primitives
|
|
ImVec2 wp = ImGui::GetWindowPos();
|
|
constexpr float kPi = 3.14159265f;
|
|
|
|
// -------------------------------------------------------------------
|
|
// Vertical centering: estimate total content height
|
|
// -------------------------------------------------------------------
|
|
float lineH = ImGui::GetTextLineHeightWithSpacing();
|
|
float titleH = Type().h5() ? Type().h5()->LegacySize : lineH * 1.5f;
|
|
float spinnerD = shutElem("spinner-radius", 20.0f) * 2.0f + 12.0f;
|
|
float statusH = lineH * 2.0f;
|
|
float sepH = lineH;
|
|
float panelH = shutElem("panel-max-height", 160.0f);
|
|
float totalH = titleH + spinnerD + statusH + sepH + panelH + lineH * 4.0f;
|
|
float y_start = (vp_size.y - totalH) * 0.5f;
|
|
if (y_start < lineH * 2.0f) y_start = lineH * 2.0f;
|
|
ImGui::SetCursorPosY(y_start);
|
|
|
|
// -------------------------------------------------------------------
|
|
// 1. Title — large, gold/amber
|
|
// -------------------------------------------------------------------
|
|
{
|
|
const char* title = "Shutting Down";
|
|
ImGui::PushFont(Type().h5());
|
|
ImVec2 ts = ImGui::CalcTextSize(title);
|
|
ImGui::SetCursorPosX(cx - ts.x * 0.5f);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning())); // theme amber
|
|
ImGui::TextUnformatted(title);
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopFont();
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
ImGui::Spacing();
|
|
ImGui::Spacing();
|
|
|
|
// -------------------------------------------------------------------
|
|
// 2. Animated arc spinner
|
|
// -------------------------------------------------------------------
|
|
{
|
|
float r = shutElem("spinner-radius", 20.0f);
|
|
float thick = shutElem("spinner-thickness", 3.0f);
|
|
// Screen-space centre for draw list
|
|
ImVec2 sc(wp.x + cx, wp.y + ImGui::GetCursorPosY() + r + 2.0f);
|
|
|
|
// Background ring (dim)
|
|
dl->PathArcTo(sc, r, 0.0f, kPi * 2.0f, 48);
|
|
dl->PathStroke(ui::schema::UI().resolveColor("var(--spinner-track)", IM_COL32(255, 255, 255, 25)), 0, thick);
|
|
|
|
// Spinning foreground arc (~270°)
|
|
float angle = shutdown_timer_ * shutElem("spinner-speed", 2.5f);
|
|
float a_min = angle;
|
|
float a_max = angle + kPi * 1.5f;
|
|
dl->PathArcTo(sc, r, a_min, a_max, 36);
|
|
dl->PathStroke(ui::schema::UI().resolveColor("var(--spinner-active)", IM_COL32(255, 218, 0, 200)), 0, thick);
|
|
|
|
// Advance cursor past the spinner
|
|
ImGui::Dummy(ImVec2(0, r * 2.0f + 8.0f));
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
|
|
// -------------------------------------------------------------------
|
|
// 3. Phase status (what the shutdown thread is doing)
|
|
// -------------------------------------------------------------------
|
|
if (!shutdown_status_.empty()) {
|
|
ImVec2 ts = ImGui::CalcTextSize(shutdown_status_.c_str());
|
|
ImGui::SetCursorPosX(cx - ts.x * 0.5f);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.75f, 0.75f, 0.75f, 1.0f));
|
|
ImGui::TextUnformatted(shutdown_status_.c_str());
|
|
ImGui::PopStyleColor();
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
|
|
// -------------------------------------------------------------------
|
|
// 4. Elapsed time — small, dim
|
|
// -------------------------------------------------------------------
|
|
{
|
|
char elapsed[64];
|
|
int secs = (int)shutdown_timer_;
|
|
if (secs < 60)
|
|
snprintf(elapsed, sizeof(elapsed), "%d seconds", secs);
|
|
else
|
|
snprintf(elapsed, sizeof(elapsed), "%d min %d sec", secs / 60, secs % 60);
|
|
|
|
ImGui::PushFont(Type().caption());
|
|
ImVec2 ts = ImGui::CalcTextSize(elapsed);
|
|
ImGui::SetCursorPosX(cx - ts.x * 0.5f);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.45f, 0.45f, 0.45f, 1.0f));
|
|
ImGui::TextUnformatted(elapsed);
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopFont();
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 4b. Force Quit button — appears once the shutdown appears stalled (or a hard-ceiling backstop)
|
|
// -------------------------------------------------------------------
|
|
if (allowForceQuit) {
|
|
ImGui::Spacing();
|
|
ImGui::Spacing();
|
|
// State-aware caution: while the status is a daemon flush/exit step, force-quitting risks the
|
|
// chainstate; say so instead of a bare button.
|
|
if (shutdownStalled && !shutdown_status_.empty()) {
|
|
std::string stalledMsg = "Still \"" + shutdown_status_ + "\" — force quitting now may corrupt chain data.";
|
|
ImVec2 ms = ImGui::CalcTextSize(stalledMsg.c_str());
|
|
ImGui::SetCursorPosX(cx - ms.x * 0.5f);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
|
|
ImGui::TextUnformatted(stalledMsg.c_str());
|
|
ImGui::PopStyleColor();
|
|
ImGui::Spacing();
|
|
}
|
|
const char* forceLabel = TR("force_quit");
|
|
ImVec2 btnSize(ImGui::CalcTextSize(forceLabel).x + 32.0f, 0);
|
|
ImGui::SetCursorPosX(cx - btnSize.x * 0.5f);
|
|
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.15f, 0.15f, 0.9f));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.75f, 0.2f, 0.2f, 1.0f));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.5f, 0.1f, 0.1f, 1.0f));
|
|
if (ui::material::TactileButton(forceLabel, btnSize)) {
|
|
force_quit_confirm_ = true;
|
|
}
|
|
if (ImGui::IsItemHovered()) {
|
|
ui::material::Tooltip("%s", TR("force_quit_warning"));
|
|
}
|
|
ImGui::PopStyleColor(3);
|
|
}
|
|
|
|
// Force Quit confirmation popup
|
|
if (force_quit_confirm_) {
|
|
ImGui::OpenPopup("##ForceQuitConfirm");
|
|
force_quit_confirm_ = false;
|
|
}
|
|
ImVec2 popupCenter(wp.x + vp_size.x * 0.5f, wp.y + vp_size.y * 0.5f);
|
|
ImGui::SetNextWindowPos(popupCenter, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
|
if (ImGui::BeginPopupModal("##ForceQuitConfirm", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
|
|
ImGui::PushFont(Type().subtitle1());
|
|
ImGui::TextUnformatted(TR("force_quit_confirm_title"));
|
|
ImGui::PopFont();
|
|
ImGui::Spacing();
|
|
ImGui::TextUnformatted(TR("force_quit_confirm_msg"));
|
|
ImGui::Spacing();
|
|
ImGui::Spacing();
|
|
|
|
float btnW = 120.0f;
|
|
float totalW = btnW * 2 + ImGui::GetStyle().ItemSpacing.x;
|
|
ImGui::SetCursorPosX((ImGui::GetWindowWidth() - totalW) * 0.5f);
|
|
|
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 0))) {
|
|
ImGui::CloseCurrentPopup();
|
|
}
|
|
ImGui::SameLine();
|
|
// Destructive action — theme error color (alpha-varied for hover/active) instead of hardcoded red.
|
|
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 210)));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(Error()));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 160)));
|
|
if (ui::material::TactileButton(TR("force_quit_yes"), ImVec2(btnW, 0))) {
|
|
DEBUG_LOGF("Force quit confirmed by user after %.0fs\n", shutdown_timer_);
|
|
shutdown_complete_ = true;
|
|
ImGui::CloseCurrentPopup();
|
|
}
|
|
ImGui::PopStyleColor(3);
|
|
ImGui::EndPopup();
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
ImGui::Spacing();
|
|
|
|
// -------------------------------------------------------------------
|
|
// 5. Separator line
|
|
// -------------------------------------------------------------------
|
|
{
|
|
float pad = vp_size.x * shutElem("separator-pad-fraction", 0.25f);
|
|
ImVec2 p0(wp.x + pad, wp.y + ImGui::GetCursorPosY());
|
|
ImVec2 p1(wp.x + vp_size.x - pad, p0.y);
|
|
dl->AddLine(p0, p1, ui::schema::UI().resolveColor("var(--status-divider)", IM_COL32(255, 255, 255, 30)), 1.0f);
|
|
ImGui::Dummy(ImVec2(0, 4.0f));
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
|
|
// -------------------------------------------------------------------
|
|
// 6. Daemon output panel — terminal-style box
|
|
// -------------------------------------------------------------------
|
|
if (daemon_controller_) {
|
|
auto lines = daemon_controller_->recentLines(8);
|
|
if (!lines.empty()) {
|
|
float panelW = vp_size.x * shutElem("panel-width-fraction", 0.70f);
|
|
float panelX = cx - panelW * 0.5f;
|
|
float panelH = shutElem("panel-max-height", 160.0f);
|
|
|
|
// Panel background (dark, rounded)
|
|
ImVec2 panelMin(wp.x + panelX, wp.y + ImGui::GetCursorPosY());
|
|
ImVec2 panelMax(panelMin.x + panelW, panelMin.y + panelH);
|
|
dl->AddRectFilled(panelMin, panelMax,
|
|
ui::schema::UI().resolveColor("var(--shutdown-panel-bg)", IM_COL32(12, 14, 20, 220)), shutElem("panel-rounding", 6.0f));
|
|
dl->AddRect(panelMin, panelMax,
|
|
ui::schema::UI().resolveColor("var(--shutdown-panel-border)", IM_COL32(255, 255, 255, 18)), shutElem("panel-rounding", 6.0f), 0, 1.0f);
|
|
|
|
// Label above lines
|
|
float panelPad = shutElem("panel-padding", 12.0f);
|
|
ImGui::SetCursorPos(ImVec2(panelX + panelPad,
|
|
ImGui::GetCursorPosY() + panelPad));
|
|
ImGui::PushFont(Type().caption());
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.45f, 0.45f, 0.50f, 1.0f));
|
|
ImGui::TextUnformatted("dragonxd output");
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopFont();
|
|
|
|
ImGui::Spacing();
|
|
|
|
// Output lines in green — clip to panel bounds
|
|
float leftPad = panelX + panelPad;
|
|
float maxTextW = panelW - panelPad * 2.0f;
|
|
ImGui::PushClipRect(panelMin, panelMax, true);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.4f, 0.78f, 0.4f, 0.9f));
|
|
ImGui::PushFont(Type().caption());
|
|
for (const auto& line : lines) {
|
|
// Stop rendering if we've reached near the panel bottom
|
|
float curY = wp.y + ImGui::GetCursorPosY();
|
|
if (curY + ImGui::GetTextLineHeight() > panelMax.y - panelPad)
|
|
break;
|
|
|
|
ImGui::SetCursorPosX(leftPad);
|
|
// Truncate if wider than panel
|
|
ImVec2 ts = ImGui::CalcTextSize(line.c_str());
|
|
if (ts.x > maxTextW && line.size() > 10) {
|
|
// Binary search for fit is overkill; just trim chars
|
|
size_t maxChars = (size_t)(line.size() * (maxTextW / ts.x));
|
|
if (maxChars > 3) {
|
|
std::string trunc = line.substr(0, maxChars - 3) + "...";
|
|
ImGui::TextUnformatted(trunc.c_str());
|
|
} else {
|
|
ImGui::TextUnformatted("...");
|
|
}
|
|
} else {
|
|
ImGui::TextUnformatted(line.c_str());
|
|
}
|
|
}
|
|
ImGui::PopFont();
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopClipRect();
|
|
|
|
// Advance cursor past the panel
|
|
float panelBottom = panelMax.y - wp.y;
|
|
ImGui::SetCursorPosY(panelBottom + 4.0f);
|
|
ImGui::Dummy(ImVec2(0, 0));
|
|
}
|
|
}
|
|
|
|
ImGui::End();
|
|
ImGui::PopStyleVar(2);
|
|
ImGui::PopStyleColor();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Loading Overlay — shown inside content area while daemon is starting/syncing
|
|
// ============================================================================
|
|
|
|
void App::renderLoadingOverlay(float contentH)
|
|
{
|
|
using namespace ui::material;
|
|
constexpr float kPi = 3.14159265f;
|
|
|
|
auto loadElem = [](const char* key, float fb) {
|
|
float v = ui::schema::UI().drawElement("screens.loading", key).size;
|
|
return v >= 0 ? v : fb;
|
|
};
|
|
|
|
loading_timer_ += ImGui::GetIO().DeltaTime;
|
|
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
ImVec2 wp = ImGui::GetWindowPos();
|
|
ImVec2 ws = ImGui::GetWindowSize();
|
|
|
|
// Layout constants
|
|
float lineH = ImGui::GetTextLineHeightWithSpacing();
|
|
float spinnerR = loadElem("spinner-radius", 18.0f);
|
|
float gap = loadElem("vertical-gap", 8.0f);
|
|
float barH = loadElem("progress-bar", 6.0f);
|
|
float barW = loadElem("progress-width", 260.0f);
|
|
float cx = ws.x * 0.5f; // centre X (local coords)
|
|
|
|
// Estimate total block height for vertical centering
|
|
float totalH = spinnerR * 2.0f + gap + lineH * 2.0f + gap + barH + gap + lineH;
|
|
float yOff = (ws.y - totalH) * 0.5f;
|
|
if (yOff < lineH * 2.0f) yOff = lineH * 2.0f;
|
|
|
|
// Screen-space Y cursor (absolute)
|
|
float curY = wp.y + yOff;
|
|
|
|
// -------------------------------------------------------------------
|
|
// 1. Animated arc spinner
|
|
// -------------------------------------------------------------------
|
|
{
|
|
float r = spinnerR;
|
|
float thick = loadElem("spinner-thickness", 2.5f);
|
|
ImVec2 sc(wp.x + cx, curY + r + 2.0f);
|
|
|
|
// Background ring (dim)
|
|
dl->PathArcTo(sc, r, 0.0f, kPi * 2.0f, 48);
|
|
dl->PathStroke(ui::schema::UI().resolveColor("var(--spinner-track)",
|
|
IM_COL32(255, 255, 255, 25)), 0, thick);
|
|
|
|
// Spinning foreground arc (~270°)
|
|
float angle = loading_timer_ * loadElem("spinner-speed", 2.5f);
|
|
dl->PathArcTo(sc, r, angle, angle + kPi * 1.5f, 36);
|
|
dl->PathStroke(ui::schema::UI().resolveColor("var(--spinner-active)",
|
|
IM_COL32(255, 218, 0, 200)), 0, thick);
|
|
|
|
curY += r * 2.0f + gap + 4.0f;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 2. Connection / daemon status text
|
|
// -------------------------------------------------------------------
|
|
{
|
|
const char* statusText = connection_status_.c_str();
|
|
ImFont* font = Type().subtitle1();
|
|
if (!font) font = ImGui::GetFont();
|
|
ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0.0f, statusText);
|
|
dl->AddText(font, font->LegacySize,
|
|
ImVec2(wp.x + cx - ts.x * 0.5f, curY),
|
|
IM_COL32(220, 220, 220, 255), statusText);
|
|
curY += ts.y + gap;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 2b. Warmup description (subtitle explaining what's happening)
|
|
// -------------------------------------------------------------------
|
|
if ((state_.warming_up || state_.daemon_initializing) && !state_.warmup_description.empty()) {
|
|
const char* descText = state_.warmup_description.c_str();
|
|
ImFont* capFont = Type().caption();
|
|
if (!capFont) capFont = ImGui::GetFont();
|
|
ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, descText);
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(wp.x + cx - ts.x * 0.5f, curY),
|
|
IM_COL32(160, 160, 160, 200), descText);
|
|
curY += ts.y + gap;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 2c. Sapling note witness rebuild progress (post-rescan phase, runs inside warmup)
|
|
// -------------------------------------------------------------------
|
|
if (state_.sync.building_witnesses) {
|
|
float progress = state_.sync.witness_progress;
|
|
if (progress < 0.0f) progress = 0.0f;
|
|
if (progress > 1.0f) progress = 1.0f;
|
|
float barRadius = loadElem("progress-bar", 3.0f);
|
|
float barX = wp.x + cx - barW * 0.5f;
|
|
ImVec2 barMin(barX, curY);
|
|
ImVec2 barMax(barX + barW, curY + barH);
|
|
dl->AddRectFilled(barMin, barMax,
|
|
ui::schema::UI().resolveColor("var(--progress-track)", IM_COL32(255, 255, 255, 30)), barRadius);
|
|
ImVec2 fillMax(barMin.x + barW * progress, barMax.y);
|
|
if (fillMax.x > barMin.x + 1.0f) {
|
|
dl->AddRectFilled(barMin, fillMax,
|
|
ui::schema::UI().resolveColor("var(--primary)", IM_COL32(255, 218, 0, 200)), barRadius);
|
|
}
|
|
curY += barH + gap;
|
|
|
|
char wbuf[128];
|
|
if (state_.sync.witness_phase == 2 && progress > 0.01f && state_.sync.witness_remaining > 0)
|
|
snprintf(wbuf, sizeof(wbuf), "Rebuilding witness cache %.0f%% — %d blocks left",
|
|
progress * 100.0f, state_.sync.witness_remaining);
|
|
else if (state_.sync.witness_phase == 2 && progress > 0.01f)
|
|
snprintf(wbuf, sizeof(wbuf), "Rebuilding witness cache %.0f%%", progress * 100.0f);
|
|
else if (progress > 0.01f)
|
|
snprintf(wbuf, sizeof(wbuf), "Setting initial Sapling witnesses %.0f%%", progress * 100.0f);
|
|
else
|
|
snprintf(wbuf, sizeof(wbuf), "Rebuilding Sapling note witnesses…");
|
|
ImFont* capFont = Type().caption();
|
|
if (!capFont) capFont = ImGui::GetFont();
|
|
ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, wbuf);
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(wp.x + cx - ts.x * 0.5f, curY),
|
|
IM_COL32(150, 150, 150, 255), wbuf);
|
|
curY += ts.y + gap;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 3. Sync progress bar (if connected and syncing)
|
|
// -------------------------------------------------------------------
|
|
if (state_.connected && state_.sync.syncing) {
|
|
float progress = static_cast<float>(state_.sync.verification_progress);
|
|
float barRadius = loadElem("progress-bar", 3.0f);
|
|
|
|
float barX = wp.x + cx - barW * 0.5f;
|
|
ImVec2 barMin(barX, curY);
|
|
ImVec2 barMax(barX + barW, curY + barH);
|
|
|
|
// Track background
|
|
dl->AddRectFilled(barMin, barMax,
|
|
ui::schema::UI().resolveColor("var(--progress-track)",
|
|
IM_COL32(255, 255, 255, 30)), barRadius);
|
|
|
|
// Filled portion
|
|
ImVec2 fillMax(barMin.x + barW * progress, barMax.y);
|
|
if (fillMax.x > barMin.x + 1.0f) {
|
|
dl->AddRectFilled(barMin, fillMax,
|
|
ui::schema::UI().resolveColor("var(--primary)",
|
|
IM_COL32(255, 218, 0, 200)), barRadius);
|
|
}
|
|
|
|
curY += barH + gap;
|
|
|
|
// Progress text — "Syncing 45.2% — Block 123456 / 234567"
|
|
char syncBuf[128];
|
|
snprintf(syncBuf, sizeof(syncBuf), "Syncing %.1f%% — Block %d / %d",
|
|
progress * 100.0f, state_.sync.blocks, state_.sync.headers);
|
|
ImFont* capFont = Type().caption();
|
|
if (!capFont) capFont = ImGui::GetFont();
|
|
ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, syncBuf);
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(wp.x + cx - ts.x * 0.5f, curY),
|
|
IM_COL32(150, 150, 150, 255), syncBuf);
|
|
curY += ts.y + gap;
|
|
} else if (!state_.connected && state_.sync.blocks > 0) {
|
|
// Show last known block height while reconnecting
|
|
char blockBuf[64];
|
|
snprintf(blockBuf, sizeof(blockBuf), "Last block: %d", state_.sync.blocks);
|
|
ImFont* capFont = Type().caption();
|
|
if (!capFont) capFont = ImGui::GetFont();
|
|
ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, blockBuf);
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(wp.x + cx - ts.x * 0.5f, curY),
|
|
IM_COL32(130, 130, 130, 255), blockBuf);
|
|
curY += ts.y + gap;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 3b. Deferred encryption status
|
|
// -------------------------------------------------------------------
|
|
if (wallet_security_.hasDeferredEncryption()) {
|
|
curY += gap;
|
|
ImFont* capFont = Type().caption();
|
|
if (!capFont) capFont = ImGui::GetFont();
|
|
|
|
const char* encLabel = encrypt_in_progress_
|
|
? "Encrypting wallet..."
|
|
: "Waiting for daemon to encrypt wallet...";
|
|
ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, encLabel);
|
|
ImU32 encCol = IM_COL32(255, 218, 0, 200);
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(wp.x + cx - ts.x * 0.5f, curY), encCol, encLabel);
|
|
curY += ts.y + gap * 0.5f;
|
|
|
|
// Indeterminate progress bar
|
|
float encBarW = barW * 0.6f;
|
|
float encBarH = 4.0f;
|
|
float encBarX = wp.x + cx - encBarW * 0.5f;
|
|
dl->AddRectFilled(ImVec2(encBarX, curY), ImVec2(encBarX + encBarW, curY + encBarH),
|
|
IM_COL32(255, 255, 255, 20), 2.0f);
|
|
float t = loading_timer_;
|
|
float pulse = 0.5f + 0.5f * sinf(t * 2.0f);
|
|
float segW = encBarW * 0.3f;
|
|
float segX = encBarX + (encBarW - segW) * pulse;
|
|
dl->AddRectFilled(ImVec2(segX, curY), ImVec2(segX + segW, curY + encBarH),
|
|
encCol, 2.0f);
|
|
curY += encBarH + gap;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 3c. Daemon crash error message
|
|
// -------------------------------------------------------------------
|
|
if (daemon_controller_ &&
|
|
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error) {
|
|
curY += gap;
|
|
ImFont* capFont = Type().caption();
|
|
if (!capFont) capFont = ImGui::GetFont();
|
|
ImFont* bodyFont2 = Type().body2();
|
|
if (!bodyFont2) bodyFont2 = ImGui::GetFont();
|
|
|
|
// Error title
|
|
const char* errTitle = "Daemon Error";
|
|
ImVec2 ts = bodyFont2->CalcTextSizeA(bodyFont2->LegacySize, FLT_MAX, 0.0f, errTitle);
|
|
dl->AddText(bodyFont2, bodyFont2->LegacySize,
|
|
ImVec2(wp.x + cx - ts.x * 0.5f, curY),
|
|
IM_COL32(255, 90, 90, 255), errTitle);
|
|
curY += ts.y + gap * 0.5f;
|
|
|
|
// Error details (wrapped) — show full diagnostic info
|
|
const std::string& errDetail = daemon_controller_->lastError();
|
|
if (!errDetail.empty()) {
|
|
float wrapW = ws.x * 0.8f;
|
|
if (wrapW > 700.0f) wrapW = 700.0f;
|
|
ImVec2 es = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, errDetail.c_str());
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(wp.x + cx - wrapW * 0.5f, curY),
|
|
IM_COL32(255, 180, 180, 220), errDetail.c_str(), nullptr, wrapW);
|
|
curY += es.y + gap;
|
|
}
|
|
|
|
// Crash count hint
|
|
if (daemon_controller_->crashCount() >= 3) {
|
|
const char* hint = "Use Settings > Restart Daemon to try again";
|
|
ImVec2 hs2 = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, hint);
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(wp.x + cx - hs2.x * 0.5f, curY),
|
|
IM_COL32(200, 200, 200, 180), hint);
|
|
curY += hs2.y + gap;
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 4. Daemon output snippet (last few lines, if embedded)
|
|
// -------------------------------------------------------------------
|
|
if (daemon_controller_) {
|
|
auto lines = daemon_controller_->recentLines(8);
|
|
if (!lines.empty()) {
|
|
curY += gap;
|
|
|
|
float panelW = ws.x * 0.85f;
|
|
if (panelW > 900.0f) panelW = 900.0f;
|
|
float panelX = wp.x + cx - panelW * 0.5f;
|
|
float panelPad = 8.0f;
|
|
|
|
ImFont* capFont = Type().caption();
|
|
if (!capFont) capFont = ImGui::GetFont();
|
|
float panelLineH = capFont->LegacySize + 4.0f;
|
|
float panelContentH = panelPad * 2.0f + panelLineH * (float)lines.size();
|
|
|
|
ImVec2 panelMin(panelX, curY);
|
|
ImVec2 panelMax(panelX + panelW, curY + panelContentH);
|
|
|
|
dl->AddRectFilled(panelMin, panelMax,
|
|
ui::schema::UI().resolveColor("var(--shutdown-panel-bg)",
|
|
IM_COL32(12, 14, 20, 180)), 6.0f);
|
|
dl->AddRect(panelMin, panelMax,
|
|
ui::schema::UI().resolveColor("var(--shutdown-panel-border)",
|
|
IM_COL32(255, 255, 255, 15)), 6.0f, 0, 1.0f);
|
|
|
|
float textY = curY + panelPad;
|
|
float maxTextW = panelW - panelPad * 2.0f;
|
|
ImU32 textCol = IM_COL32(102, 199, 102, 217); // green tint
|
|
|
|
for (const auto& line : lines) {
|
|
std::string display = line;
|
|
ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, display.c_str());
|
|
if (ts.x > maxTextW && display.size() > 10) {
|
|
size_t maxChars = (size_t)(display.size() * (maxTextW / ts.x));
|
|
if (maxChars > 3) display = display.substr(0, maxChars - 3) + "...";
|
|
else display = "...";
|
|
}
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(panelX + panelPad, textY),
|
|
textCol, display.c_str());
|
|
textY += panelLineH;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void App::shutdown()
|
|
{
|
|
// Clean up bootstrap if running
|
|
if (bootstrap_) {
|
|
bootstrap_->cancel();
|
|
bootstrap_.reset();
|
|
}
|
|
|
|
// If beginShutdown() was never called (e.g. direct exit),
|
|
// do synchronous shutdown as fallback.
|
|
if (!shutting_down_) {
|
|
DEBUG_LOGF("Synchronous shutdown fallback...\n");
|
|
async_tasks_.cancelAll();
|
|
async_tasks_.joinAll();
|
|
if (rpc_) rpc_->requestAbort(); // unblock any in-flight curl before joining
|
|
if (fast_rpc_) fast_rpc_->requestAbort();
|
|
if (worker_) {
|
|
worker_->stop();
|
|
}
|
|
if (fast_worker_) {
|
|
fast_worker_->stop();
|
|
}
|
|
if (settings_) {
|
|
settings_->save();
|
|
}
|
|
if (daemon_controller_) {
|
|
stopEmbeddedDaemon();
|
|
}
|
|
if (rpc_) {
|
|
rpc_->disconnect();
|
|
}
|
|
if (fast_rpc_) {
|
|
fast_rpc_->disconnect();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Wait for the async shutdown thread to finish
|
|
if (shutdown_thread_.joinable()) {
|
|
shutdown_thread_.join();
|
|
}
|
|
async_tasks_.joinAll();
|
|
// Join the RPC worker thread (was signaled in beginShutdown via requestStop)
|
|
if (worker_) {
|
|
worker_->stop();
|
|
}
|
|
if (fast_worker_) {
|
|
fast_worker_->stop();
|
|
}
|
|
// Disconnect RPC after worker is fully stopped (safe — no curl in flight)
|
|
if (rpc_) {
|
|
rpc_->disconnect();
|
|
}
|
|
if (fast_rpc_) {
|
|
fast_rpc_->disconnect();
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// First-run detection
|
|
// ===========================================================================
|
|
|
|
bool App::isFirstRun() const {
|
|
std::string dataDir = util::Platform::getDragonXDataDir();
|
|
std::error_code ec;
|
|
return !std::filesystem::exists(dataDir, ec) ||
|
|
!std::filesystem::exists(std::filesystem::path(dataDir) / "blocks", ec);
|
|
}
|
|
|
|
bool App::hasPinVault() const {
|
|
return vault_ && vault_->hasVault() && settings_ && settings_->getPinEnabled();
|
|
}
|
|
|
|
bool App::debugGateRequiresAuth() const {
|
|
return hasPinVault() || state_.encrypted;
|
|
}
|
|
|
|
void App::verifyDebugCredential(const std::string& secret, std::function<void(bool)> cb) {
|
|
if (!cb) return;
|
|
// A quick-unlock PIN vault → verify the PIN by deriving it (pure crypto, no wallet side effects).
|
|
if (hasPinVault()) {
|
|
auto* w = worker_.get();
|
|
if (!w) { cb(false); return; }
|
|
w->post([this, s = secret, cb]() mutable -> rpc::RPCWorker::MainCb {
|
|
std::string pass;
|
|
bool ok = vault_ && vault_->retrieve(s, pass);
|
|
if (!pass.empty()) util::SecureVault::secureZero(pass.data(), pass.size());
|
|
if (!s.empty()) util::SecureVault::secureZero(&s[0], s.size());
|
|
return [cb, ok]() { cb(ok); };
|
|
});
|
|
return;
|
|
}
|
|
// Encrypted wallet, no PIN → verify the passphrase via walletpassphrase (re-unlocks with the
|
|
// usual timeout; a wrong passphrase throws).
|
|
if (state_.encrypted) {
|
|
auto* w = worker_.get();
|
|
auto* r = rpc_.get();
|
|
if (!w || !r) { cb(false); return; }
|
|
int autoLock = settings_ ? settings_->getAutoLockTimeout() : 300;
|
|
int timeout = (autoLock > 0) ? std::max(600, autoLock * 2) : 86400;
|
|
w->post([r, s = secret, timeout, cb]() mutable -> rpc::RPCWorker::MainCb {
|
|
bool ok = false;
|
|
try {
|
|
if (r && r->isConnected()) { r->call("walletpassphrase", {s, timeout}); ok = true; }
|
|
} catch (...) { ok = false; }
|
|
if (!s.empty()) util::SecureVault::secureZero(&s[0], s.size());
|
|
return [cb, ok]() { cb(ok); };
|
|
});
|
|
return;
|
|
}
|
|
// No credential set — nothing to verify.
|
|
cb(true);
|
|
}
|
|
|
|
bool App::hasPendingRPCResults() const {
|
|
return (worker_ && worker_->hasPendingResults())
|
|
|| (fast_worker_ && fast_worker_->hasPendingResults());
|
|
}
|
|
|
|
std::string App::transactionSendProgressText() const
|
|
{
|
|
using Job = services::NetworkRefreshService::Job;
|
|
if (send_submissions_in_flight_ > 0) return TR("tx_progress_submitting");
|
|
|
|
if (!pending_opids_.empty()) {
|
|
char buf[128];
|
|
snprintf(buf, sizeof(buf), TR("tx_progress_waiting_ops"), (int)pending_opids_.size());
|
|
return buf;
|
|
}
|
|
|
|
if (addresses_dirty_ || network_refresh_.jobInProgress(Job::Addresses)) {
|
|
return TR("tx_progress_balances");
|
|
}
|
|
|
|
if (transactions_dirty_ || network_refresh_.jobInProgress(Job::Transactions)) {
|
|
return TR("tx_progress_history");
|
|
}
|
|
|
|
return TR("tx_progress_finalizing");
|
|
}
|
|
|
|
std::string App::transactionRefreshProgressText() const
|
|
{
|
|
using Job = services::NetworkRefreshService::Job;
|
|
bool running = network_refresh_.jobInProgress(Job::Transactions);
|
|
bool canRefresh = state_.connected && !state_.warming_up && !state_.isLocked();
|
|
if (!running && !(canRefresh && transactions_dirty_)) return {};
|
|
|
|
char buf[128];
|
|
|
|
// A just-sent transaction being enriched always surfaces — the user is actively waiting on it.
|
|
if (!send_txids_.empty()) {
|
|
snprintf(buf, sizeof(buf), TR("tx_loading_enriching_sends"), (int)send_txids_.size());
|
|
return buf;
|
|
}
|
|
|
|
// Initial bulk history load: the incremental shielded scan streams older transactions in over
|
|
// many refresh cycles, so the first batch can appear long before the history is complete. Keep
|
|
// the user informed (with a %) even after rows are on screen, so they know it's still loading.
|
|
// Progress = fraction of z-addresses scanned at least once (monotonic). Goes quiet for good once
|
|
// the first full scan completes; routine per-block re-scans don't re-trigger it.
|
|
if (!initial_history_scan_complete_ && !state_.z_addresses.empty()) {
|
|
std::size_t total = state_.z_addresses.size();
|
|
std::size_t scanned = std::min(shielded_history_scan_heights_.size(), total);
|
|
int pct = total > 0 ? static_cast<int>((scanned * 100) / total) : 0;
|
|
if (pct > 99) pct = 99; // 100% is the "complete" state, which suppresses the banner
|
|
snprintf(buf, sizeof(buf), TR("tx_loading_history_progress"), pct);
|
|
return buf;
|
|
}
|
|
|
|
// Once history is on screen, stay quiet for routine background refreshes. The incremental
|
|
// shielded-receive scan re-dirties transactions_dirty_ on EVERY new block (each block moves the
|
|
// "scanned at tip" bar, invalidating prior per-address scans), so for a wallet with more
|
|
// z-addresses than the per-cycle scan budget the flag never clears — which left this banner lit
|
|
// indefinitely. New receives still appear live as they're scanned; only the INITIAL load
|
|
// (nothing displayed yet) and send enrichment (above) warrant a loading banner.
|
|
if (!state_.transactions.empty()) return {};
|
|
|
|
if (!running && transactions_dirty_) return TR("tx_loading_queued");
|
|
|
|
if (!state_.z_addresses.empty()) {
|
|
snprintf(buf, sizeof(buf), TR("tx_loading_scanning_shielded"), (int)state_.z_addresses.size());
|
|
return buf;
|
|
}
|
|
|
|
return TR("tx_loading_fetching_transparent");
|
|
}
|
|
|
|
void App::copySecretToClipboard(const std::string& secret)
|
|
{
|
|
ImGui::SetClipboardText(secret.c_str());
|
|
// Keep only an FNV-1a hash of the secret so we can later confirm the clipboard still holds it
|
|
// (and shouldn't be clobbered) WITHOUT retaining the plaintext.
|
|
std::uint64_t h = 1469598103934665603ULL;
|
|
for (unsigned char c : secret) h = (h ^ c) * 1099511628211ULL;
|
|
clipboard_secret_hash_ = secret.empty() ? 0 : h;
|
|
clipboard_clear_deadline_ = secret.empty() ? 0.0 : (ImGui::GetTime() + 45.0);
|
|
if (!secret.empty())
|
|
ui::Notifications::instance().info("Copied — clipboard auto-clears in 45s", 4.0f);
|
|
}
|
|
|
|
void App::pumpSecretClipboardClear()
|
|
{
|
|
if (clipboard_clear_deadline_ <= 0.0) return;
|
|
if (ImGui::GetTime() < clipboard_clear_deadline_) return;
|
|
// Only clear if the clipboard STILL holds our secret (the user may have copied something else).
|
|
if (const char* cb = ImGui::GetClipboardText()) {
|
|
std::uint64_t h = 1469598103934665603ULL;
|
|
for (const char* p = cb; *p; ++p) h = (h ^ static_cast<unsigned char>(*p)) * 1099511628211ULL;
|
|
if (h == clipboard_secret_hash_) ImGui::SetClipboardText("");
|
|
}
|
|
clipboard_clear_deadline_ = 0.0;
|
|
clipboard_secret_hash_ = 0;
|
|
}
|
|
|
|
void App::maybeFinishTransactionSendProgress()
|
|
{
|
|
using Job = services::NetworkRefreshService::Job;
|
|
if (!send_progress_active_) return;
|
|
if (send_submissions_in_flight_ > 0 || !pending_opids_.empty()) return;
|
|
// Finish once the spend is reflected in the balance (addresses). Do NOT wait on the transaction
|
|
// history scan: the sent tx is already shown via the optimistic pending insert, and the shielded
|
|
// history scan can stay "dirty" for a long time on wallets with many z-addresses — gating on it
|
|
// left the send-progress indicator stuck on indefinitely.
|
|
if (addresses_dirty_ || network_refresh_.jobInProgress(Job::Addresses)) return;
|
|
send_progress_active_ = false;
|
|
}
|
|
void App::restartDaemon()
|
|
{
|
|
if (!supportsFullNodeLifecycleActions()) {
|
|
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
|
|
return;
|
|
}
|
|
|
|
auto decision = daemon::DaemonController::evaluateLifecycleOperation(
|
|
daemon::DaemonController::LifecycleOperation::ManualRestart,
|
|
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning(), daemon_restarting_.load());
|
|
if (!decision.allowed) return;
|
|
daemon_restarting_ = true;
|
|
|
|
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
|
|
|
DEBUG_LOGF("[App] Restarting embedded daemon...\n");
|
|
connection_status_ = TR("sb_restarting_daemon");
|
|
|
|
// Disconnect RPC so the loading overlay appears
|
|
if (decision.disconnectRpc && rpc_ && rpc_->isConnected()) {
|
|
rpc_->disconnect();
|
|
}
|
|
onDisconnected("Daemon restart");
|
|
|
|
// Run stop + start on a background thread to avoid blocking the UI.
|
|
// The 5-second auto-retry in render() will reconnect once the daemon
|
|
// is back up.
|
|
async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) {
|
|
AppDaemonLifecycleRuntime runtime(*this);
|
|
daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
|
|
daemon_controller_->executeLifecycleOperation(decision, runtime, context);
|
|
daemon_restarting_ = false;
|
|
DEBUG_LOGF("[App] Daemon restart complete — waiting for RPC...\n");
|
|
});
|
|
}
|
|
|
|
} // namespace dragonx
|