main() built the font atlas before App::init() loaded ui.toml, so the first build used the hard-coded fallback sizes (H1 24 px, Body2 12 px). App::init() then rebuilt for a saved font scale above 1.0, still before the schema load and so still at the fallback sizes. Only the color-emoji rebuild requested for the first frame reached the ui.toml sizes, so color-emoji users built the atlas twice at font scale 1.0 and three times at 1.5. Users with chat_emoji_color=false never got that last rebuild. They built the atlas once or twice and ran the whole session at the fallback sizes, e.g. Body2 at 12 px instead of 15 and H1 at 24 px instead of 20. Now: - App::loadBaseUiSchema(), the ui.toml load moved out of App::init() unchanged, runs in main() before both InitImGui paths (DX11 and GL), together with the saved font scale and emoji style from the early settings read. It is idempotent, so the call left in App::init() is a no-op; - the two rebuild triggers in App::init() are gone; - Typography records what the atlas was built from (the 15 schema sizes, the schema's font scale, the effective emoji style and the schema generation), and preFrame() rebuilds once when they differ. While the schema generation and the emoji style are unchanged this costs two compares per frame. Measured on the Lite build (color and mono emoji, font scale 1.0 and 1.5, English and Japanese): every run builds the atlas exactly once, at the ui.toml sizes times the scale (H1 20 px and Body2 15 px at 1.0, 30 px and 22 px at 1.5), and ui.toml is loaded before the window and GL context are created. The packaged Lite zip and AppImage log one atlas build instead of two. Color-emoji screenshot sweeps match the previous build within noise. Visible changes: - users with chat_emoji_color=false had been rendering the whole UI at the fallback font sizes; they now get the designed ui.toml sizes, so every mono-emoji screen differs from before and matches the color-emoji layout; - a user skin whose [fonts] differ from ui.toml now rebuilds the atlas once when it is activated or left; on a runtime switch the atlas used to keep the old sizes until a restart. With a test skin that sets Body2 to 18 px, startup builds twice and ends at 18 px; switching to a bundled skin rebuilds once, switching among the 9 bundled skins rebuilds nothing (they carry an identical copy of [fonts]), and switching back rebuilds once; - editing ui.toml while running still gives exactly one rebuild. Not verifiable here: the Windows DX11 path with the schema loaded before its first FreeType build, macOS, and the interactive font-scale slider, Alt+scroll and emoji toggle, which were checked by reading the code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7980 lines
398 KiB
C++
7980 lines
398 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 "data/seed_migration_resume.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 "ui/windows/faq_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 "ui/node_status_banner.h"
|
|
#include "util/i18n.h"
|
|
#include "util/connect_stall.h"
|
|
#include "util/platform.h"
|
|
#include "util/text_format.h"
|
|
#include "util/text_fit.h"
|
|
#include "util/payment_uri.h"
|
|
#include "util/seed_phrase.h"
|
|
#include "util/texture_loader.h"
|
|
#include "util/svg_texture.h"
|
|
#include "ui/material/colors.h"
|
|
#include "logo_dragonx_svg.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 <sstream>
|
|
#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{}());
|
|
// Purge any plaintext key export left behind by a crashed/interrupted decrypt flow. (H-02)
|
|
sweepStaleDecryptExports();
|
|
}
|
|
App::~App()
|
|
{
|
|
wipeSecrets();
|
|
}
|
|
|
|
// Scrub every resident secret buffer. Idempotent + safe to call from the forced-exit path (main.cpp
|
|
// _Exit bypasses destructors), so key/seed material isn't left in freed heap on the real quit path. (L-05)
|
|
void App::wipeSecrets()
|
|
{
|
|
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());
|
|
sodium_memzero(export_result_, sizeof(export_result_)); // exported WIF/z-key (SECRET)
|
|
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET)
|
|
}
|
|
|
|
// Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread
|
|
// never blocks on stop()'s SIGTERM->SIGKILL->join, while start/stop still execute in FIFO order. (M-03/…)
|
|
void App::postMiningControl(std::function<void()> job)
|
|
{
|
|
{
|
|
std::lock_guard<std::mutex> lk(mining_ctl_mutex_);
|
|
if (mining_ctl_stop_) return; // shutting down — don't enqueue new mining ops
|
|
if (!mining_ctl_thread_.joinable()) {
|
|
mining_ctl_thread_ = std::thread([this]() {
|
|
for (;;) {
|
|
std::function<void()> j;
|
|
{
|
|
std::unique_lock<std::mutex> lk(mining_ctl_mutex_);
|
|
mining_ctl_cv_.wait(lk, [this]{ return mining_ctl_stop_ || !mining_ctl_queue_.empty(); });
|
|
if (mining_ctl_stop_) return; // abandon any pending jobs on shutdown
|
|
j = std::move(mining_ctl_queue_.front());
|
|
mining_ctl_queue_.pop_front();
|
|
}
|
|
j();
|
|
}
|
|
});
|
|
}
|
|
mining_ctl_queue_.push_back(std::move(job));
|
|
}
|
|
mining_ctl_cv_.notify_one();
|
|
}
|
|
|
|
// Signal the mining-control thread to stop and join it. Called at shutdown BEFORE xmrig_manager_ is stopped
|
|
// or destroyed, so no control job runs concurrently with teardown. Idempotent.
|
|
void App::stopMiningControlThread()
|
|
{
|
|
{
|
|
std::lock_guard<std::mutex> lk(mining_ctl_mutex_);
|
|
mining_ctl_stop_ = true;
|
|
}
|
|
mining_ctl_cv_.notify_all();
|
|
if (mining_ctl_thread_.joinable()) mining_ctl_thread_.join();
|
|
}
|
|
|
|
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
|
|
if (ui::IsMiningBenchmarkActive()) return; // don't auto-switch pools mid-benchmark — it restarts xmrig at the wrong thread count (L-12)
|
|
|
|
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()) {
|
|
// A thread-count retarget may still be queued on the control thread: keep its count, not the old one.
|
|
const int queued = pool_threads_target_.load(std::memory_order_relaxed);
|
|
const int threads = queued > 0 ? queued : xmrig_manager_->getRequestedThreads();
|
|
stopPoolMining();
|
|
startPoolMining(threads);
|
|
}
|
|
}
|
|
|
|
std::string App::poolMiningInstalledVersion()
|
|
{
|
|
// Not while the startup job may still be replacing the miner (`xmrig --version` would cache the old
|
|
// binary's version); pollStartupFiles() starts the detection when the job ends.
|
|
if (!startup_files_ || !startup_files_->isRunning())
|
|
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) {}
|
|
|
|
bool stopDaemonWithPolicy() override { return app_.stopEmbeddedDaemon() == daemon::StopOutcome::Exited; }
|
|
bool startDaemon() override { return app_.startEmbeddedDaemon(); }
|
|
void resetOutputOffset() override
|
|
{
|
|
app_.daemon_output_offset_ = 0;
|
|
app_.resetDaemonBusy();
|
|
}
|
|
|
|
void beginBackgroundStop(const char* reason) override
|
|
{
|
|
app_.abortMaintenanceCalls(); // a long call holds the curl lock disconnect() needs
|
|
if (app_.rpc_ && app_.rpc_->isConnected()) app_.rpc_->disconnect();
|
|
app_.onDisconnected(reason ? reason : "Daemon lifecycle");
|
|
App& app = app_;
|
|
app.async_tasks_.submit("background-stop-daemon",
|
|
[&app](const util::AsyncTaskManager::Token&) { app.stopEmbeddedDaemon(); });
|
|
}
|
|
|
|
int deleteBlockchainData() override
|
|
{
|
|
return daemon::BlockchainDataCleaner::removeBlockchainData(util::Platform::getDragonXDataDir());
|
|
}
|
|
|
|
private:
|
|
App& app_;
|
|
};
|
|
|
|
bool App::loadBaseUiSchema()
|
|
{
|
|
if (ui::schema::UISchema::instance().isLoaded()) return true;
|
|
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
|
|
}
|
|
return ui::schema::UISchema::instance().isLoaded();
|
|
}
|
|
|
|
bool App::init()
|
|
{
|
|
DEBUG_LOGF("Initializing ObsidianDragon...\n");
|
|
|
|
ui_thread_id_ = std::this_thread::get_id();
|
|
// Write the node files (Sapling params, asmap, node, miner) on first run / after an upgrade OFF the UI
|
|
// thread: the first frame no longer waits for ~88 MB of disk writes. startEmbeddedDaemon() defers (UI
|
|
// thread) or waits (workers) until the job is done, so the node never starts over half-written files.
|
|
startStartupFilesProvision();
|
|
|
|
// 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(); // no writer attached yet: synchronous
|
|
settings_->clearUpgradeSave();
|
|
}
|
|
// From here on settings_->save() is written by file_writer_ off the UI thread (flushed by main()
|
|
// at exit); a save after a fund/security-critical setter still waits for the disk (Settings::save).
|
|
settings_->setFileWriter(&file_writer_);
|
|
|
|
// 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();
|
|
|
|
// Ensure ObsidianDragon config directory and template files exist
|
|
util::Platform::ensureObsidianDragonSetup();
|
|
|
|
// W1-1 (startup): if the recorded active wallet file was moved/deleted between sessions, don't hand a
|
|
// missing -wallet=<name> to the daemon — it would auto-create a fresh empty wallet under that name,
|
|
// silently "opening" as a zero-balance wallet at launch. Fall back to the always-present default and
|
|
// warn. (The default "wallet.dat" is legitimately absent on first run, so it is skipped.) Runs before
|
|
// the vault init below so the vault is scoped to the wallet actually opened.
|
|
if (settings_) {
|
|
const std::string active = settings_->getActiveWalletFile();
|
|
if (!active.empty() && active != "wallet.dat") {
|
|
std::error_code walEc;
|
|
const std::string walPath = util::Platform::getDragonXDataDir() + "/" + active;
|
|
if (!std::filesystem::exists(walPath, walEc)) {
|
|
DEBUG_LOGF("[App] active wallet '%s' not found at startup — falling back to wallet.dat\n",
|
|
active.c_str());
|
|
settings_->setActiveWalletFile("wallet.dat");
|
|
settings_->save();
|
|
ui::Notifications::instance().warning(
|
|
std::string(TR("appx_last_used_wallet_not_found_prefix")) + active +
|
|
TR("appx_last_used_wallet_not_found_suffix"), 20.0f);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize PIN vault, scoped to the active wallet so one wallet's stored passphrase is never
|
|
// offered for another (the default wallet keeps the legacy vault.dat).
|
|
vault_ = std::make_unique<util::SecureVault>(settings_ ? settings_->getActiveWalletFile() : "");
|
|
|
|
// 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)
|
|
{
|
|
loadBaseUiSchema(); // no-op when main() already loaded it before the first font build
|
|
|
|
// 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 the saved appearance prefs (low-spec, theme effects, blur, opacity, noise). Until now they
|
|
// only took effect once the Settings page or wizard was opened, so "effects off" / low-spec reverted
|
|
// at every launch and the animated defaults kept the main loop rendering continuously. After the
|
|
// skin, because setActiveSkin reloads the effect config (not the enabled flag).
|
|
applyVisualSettings();
|
|
|
|
// 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>();
|
|
rpc_->setBusyGate(rpc_busy_gate_);
|
|
|
|
// Initialize background RPC worker thread
|
|
worker_ = std::make_unique<rpc::RPCWorker>();
|
|
worker_->start();
|
|
// HTTP lane for the market data (price / tickers / charts). Both variants: the lite build has a Market tab.
|
|
http_worker_ = std::make_unique<rpc::RPCWorker>();
|
|
http_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()) {
|
|
// Needs the final miner on disk: while the startup job may still be writing it, pollStartupFiles()
|
|
// starts this instead.
|
|
if (!startup_files_->isRunning())
|
|
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. Its saves are queued on file_writer_ (they follow every scan pass
|
|
// whose metadata changed, so they shouldn't fsync on the UI thread).
|
|
wallet_index_.setFileWriter(&file_writer_);
|
|
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::applyVisualSettings()
|
|
{
|
|
if (!settings_) return;
|
|
namespace fx = ui::effects;
|
|
const bool lowSpec = settings_->getLowSpecMode();
|
|
fx::setLowSpecMode(lowSpec);
|
|
if (lowSpec) {
|
|
// Low-spec overrides the heavy effects at runtime only; the saved prefs stay as they are so
|
|
// turning low-spec off restores them.
|
|
fx::ImGuiAcrylic::ApplyBlurAmount(0.0f);
|
|
fx::ImGuiAcrylic::SetUIOpacity(1.0f);
|
|
fx::ThemeEffects::instance().setEnabled(false);
|
|
fx::ThemeEffects::instance().setReducedTransparency(true);
|
|
ui::ConsoleTab::s_scanline_enabled = false;
|
|
} else {
|
|
const bool effects = settings_->getThemeEffectsEnabled();
|
|
fx::ImGuiAcrylic::ApplyBlurAmount(std::min(settings_->getBlurMultiplier(), fx::kAcrylicMaxBlur));
|
|
fx::ImGuiAcrylic::SetUIOpacity(settings_->getUIOpacity());
|
|
fx::ThemeEffects::instance().setEnabled(effects);
|
|
fx::ThemeEffects::instance().setReducedTransparency(!effects);
|
|
ui::ConsoleTab::s_scanline_enabled = settings_->getScanlineEnabled();
|
|
}
|
|
fx::ImGuiAcrylic::SetNoiseOpacity(settings_->getNoiseOpacity());
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
// Keep Typography's emoji-style flag in sync with the setting so any reload (font-scale, DPI, or the
|
|
// explicit request below) picks up the right emoji font. Inert unless this is a FreeType build.
|
|
if (settings_) ui::material::Typography::instance().setColorEmoji(settings_->getChatEmojiColor());
|
|
|
|
// App-wide clock format (24h/12h) — drives every user-facing timestamp via util::formatClock*.
|
|
if (settings_) util::setClock12h(settings_->getTimeFormat() == 1);
|
|
|
|
// Explicit rebuild request (e.g. the chat color-emoji toggle) — reload picks up the new emoji style.
|
|
if (font_rebuild_requested_) {
|
|
font_rebuild_requested_ = false;
|
|
auto& typo = ui::material::Typography::instance();
|
|
typo.reload(io, typo.getDpiScale());
|
|
DEBUG_LOGF("App: Font atlas rebuilt on request (chat emoji style)\n");
|
|
}
|
|
|
|
// Safety net: schema font sizes (e.g. a user skin overlay defining [fonts]) or the emoji style differ from what the
|
|
// atlas was built with. Steady state is one integer compare; covers load-order skew without unconditional rebuilds.
|
|
// Build-expanded bundled skins carry an identical copy of [fonts] (expand_themes.py), so needsRebuild sees no size
|
|
// change; user skins with their own [fonts] trigger exactly one rebuild.
|
|
if (ui::material::Typography::instance().needsRebuild()) {
|
|
auto& typo = ui::material::Typography::instance();
|
|
typo.reload(io, typo.getDpiScale());
|
|
DEBUG_LOGF("App: Font atlas rebuilt (schema font sizes / emoji style changed)\n");
|
|
}
|
|
}
|
|
|
|
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::retireLiteWallet()
|
|
{
|
|
if (!lite_wallet_) return;
|
|
lite_wallet_->retire("lite wallet reconnecting"); // non-blocking (see LiteWalletController::retire)
|
|
// Destroy off the UI thread: ~LiteWalletController joins the poll worker, which may be inside a backend call that
|
|
// does not return for minutes (no connect/request deadline). Nobody waits for this thread; it owns the controller
|
|
// outright and touches nothing else of App. On quit the process _Exit()s, so it is never joined.
|
|
auto old = std::move(lite_wallet_);
|
|
try {
|
|
std::thread([c = std::move(old)]() mutable { c.reset(); }).detach();
|
|
} catch (...) {
|
|
// No thread: the controller was already destroyed inside the failed std::thread constructor, i.e. on this
|
|
// thread — the pre-fix blocking behaviour, but no crash.
|
|
}
|
|
}
|
|
|
|
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). The old
|
|
// controller is retired (retireLiteWallet) and destroyed on a background thread: its destructor
|
|
// joins the poll worker, which can sit for minutes inside a backend call against a dead server,
|
|
// so it must never run on the UI thread. The app's auto-open loop then reopens the wallet
|
|
// against the new server.
|
|
if (!force && lite_wallet_ && lite_wallet_->walletOpen()) return;
|
|
|
|
// Never retire a controller with a wallet op (new address / import / encrypt / decrypt) in flight: litelib_execute
|
|
// always runs on the GLOBAL client, so the op would still finish — and save — on the old client while the new
|
|
// client owns the same wallet file, after its UI callback was told it failed. Defer the switch (update() retries
|
|
// every tick) until the op has been delivered, but at most kLiteServerSwitchMaxDeferSeconds so an op stuck behind
|
|
// a dead server can't pin the user to it.
|
|
const bool opInFlight = lite_wallet_ && lite_wallet_->walletOpInProgress();
|
|
const auto nowTp = std::chrono::steady_clock::now();
|
|
if (opInFlight && !lite_rebuild_pending_) lite_rebuild_deferred_at_ = nowTp;
|
|
const double deferredFor = opInFlight
|
|
? std::chrono::duration<double>(nowTp - lite_rebuild_deferred_at_).count() : 0.0;
|
|
if (!wallet::liteServerSwitchMayProceed(opInFlight, deferredFor)) {
|
|
lite_rebuild_pending_ = true;
|
|
return;
|
|
}
|
|
lite_rebuild_pending_ = false;
|
|
|
|
const auto liteConn = wallet::liteConnectionSettingsFromAppSettings(*settings_);
|
|
wallet::liteLog(std::string(force ? "Lite controller rebuilt" : "Lite controller built") +
|
|
" (preferred server: " + liteConn.stickyServerUrl + ")");
|
|
retireLiteWallet(); // no-op on the first build
|
|
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.
|
|
resetChatSession();
|
|
}
|
|
|
|
void App::update()
|
|
{
|
|
PERF_SCOPE("Update.Total");
|
|
ImGuiIO& io = ImGui::GetIO();
|
|
// Clamp the frame delta for visual interpolation: after a long pause (window minimized, an idle
|
|
// stretch, or the machine slept) NewFrame reports a large DeltaTime that would make animations jump.
|
|
if (io.DeltaTime > 0.25f) io.DeltaTime = 0.25f;
|
|
// Logic timers use real elapsed time instead (see logicDeltaTime()), capped so a sleep/resume fires
|
|
// each timer at most once rather than replaying the whole gap.
|
|
{
|
|
const auto now = std::chrono::steady_clock::now();
|
|
logic_dt_ = (last_update_time_ == std::chrono::steady_clock::time_point{})
|
|
? 0.0f
|
|
: std::min(std::chrono::duration<float>(now - last_update_time_).count(), kMaxLogicDeltaSeconds);
|
|
if (logic_dt_ < 0.0f) logic_dt_ = 0.0f;
|
|
last_update_time_ = now;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// Reap the startup node-file job (failure toast, deferred daemon start, miner version probe).
|
|
pollStartupFiles();
|
|
|
|
// If a wallet switch's daemon failed to start, revert to the previous wallet (main-thread-safe).
|
|
processWalletSwitchRevert();
|
|
|
|
// Track user interaction for auto-lock. Not in the background: a minimized window runs update() without
|
|
// new frames, so the IO state is the last frame's, and a stale mouse delta kept auto-lock from ever firing.
|
|
if (!window_background_ &&
|
|
(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();
|
|
}
|
|
|
|
// Advance the refresh timers by the real time since the last update BEFORE the RPC results are
|
|
// applied, so a hold one of those callbacks arms now (e.g. the connect-retry backoff) starts fresh
|
|
// instead of being charged the idle gap that elapsed before it existed.
|
|
network_refresh_.tick(logic_dt_);
|
|
|
|
// Drain completed RPC results back onto the main thread
|
|
if (worker_) {
|
|
worker_->drainResults();
|
|
}
|
|
if (http_worker_) {
|
|
http_worker_->drainResults(); // market data (update() also runs while minimized)
|
|
}
|
|
if (fast_worker_) {
|
|
fast_worker_->drainResults();
|
|
}
|
|
// A scan ticket whose job is gone without a result (its task threw before returning a callback) would
|
|
// hold the scan budget's one slot: complete it as timed out. Safe right after the drains: a ticket is
|
|
// issued in the same call as its job, and a job's flag clears just before its result is applied.
|
|
{
|
|
using Job = services::NetworkRefreshService::Job;
|
|
using services::ScanStream;
|
|
const double scanNow = scanClockNow();
|
|
if (!network_refresh_.jobInProgress(Job::Core)) scan_governor_.dropOrphans(ScanStream::Balance, scanNow);
|
|
if (!network_refresh_.jobInProgress(Job::Addresses)) scan_governor_.dropOrphans(ScanStream::Addresses, scanNow);
|
|
// The chat scans aren't refresh jobs: their in-flight flag is set with the ticket and cleared by the reply
|
|
// that completes it.
|
|
if (!chat_fast_scan_in_flight_) scan_governor_.dropOrphans(ScanStream::ChatFast, scanNow);
|
|
if (!chat_note_scan_in_flight_) scan_governor_.dropOrphans(ScanStream::ChatNotes, scanNow);
|
|
if (!network_refresh_.jobInProgress(Job::Transactions)) {
|
|
scan_governor_.dropOrphans(ScanStream::History, scanNow);
|
|
scan_governor_.dropOrphans(ScanStream::RecentTx, scanNow);
|
|
}
|
|
}
|
|
|
|
// Release a deferred history-snapshot store / retry a failed write (O(1) when idle). After the drains, so
|
|
// a store a callback deferred this tick is seen; capture_mode_ returned earlier, so never during a sweep.
|
|
pumpTransactionHistoryStore();
|
|
|
|
// 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();
|
|
// Deliver finished wallet ops (new address / import / encrypt / decrypt) to their UI callbacks.
|
|
lite_wallet_->pumpWalletOps();
|
|
// A server switch deferred behind an in-flight wallet op: retry it. This may replace lite_wallet_; the rest of
|
|
// this block then runs on the new controller, exactly as on the tick after a Network-tab switch.
|
|
if (lite_rebuild_pending_) rebuildLiteWallet(/*force=*/true);
|
|
|
|
// 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();
|
|
}
|
|
|
|
// The controller's open/lifecycle/sync/send/console work runs on its own threads and the UI
|
|
// shows progress for it: keep frames coming while any of it is in flight.
|
|
if (lite_wallet_->openInProgress() || lite_wallet_->lifecycleRequestInProgress() ||
|
|
lite_wallet_->scanInProgress() || lite_wallet_->broadcastInProgress() ||
|
|
lite_wallet_->consoleCommandInProgress() || lite_wallet_->walletOpInProgress() ||
|
|
lite_rebuild_pending_) {
|
|
ui::material::RequestProgressFrame();
|
|
}
|
|
|
|
// 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(TR("appx_wallet_open_failed_prefix")) + 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);
|
|
// Chat note-buffer: recompute the verified/maturing self-note estimates from the fresh model.
|
|
refreshChatNoteBudget(liteModel);
|
|
}
|
|
// Deliver a completed async send/shield result. Route by the in-flight op that owns the single
|
|
// broadcast channel: a user Send-tab send goes to lite_send_callback_; a chat send / contact
|
|
// request resolves its echo (real status — not the old optimistic "Sent"); a note-buffer split
|
|
// just logs. Because the controller runs one broadcast at a time, inflight_op_ correlates the
|
|
// one global result with no txid matching.
|
|
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);
|
|
const LiteInflightOp finished = inflight_op_;
|
|
inflight_op_ = LiteInflightOp{}; // channel is free again
|
|
switch (finished.kind) {
|
|
case LiteOpKind::ChatSend:
|
|
case LiteOpKind::ContactRequest:
|
|
onChatBroadcastResult(finished, broadcast.ok, broadcast.error);
|
|
break;
|
|
case LiteOpKind::Split:
|
|
// Buffer split done; the next refresh re-counts the new (maturing) notes.
|
|
break;
|
|
case LiteOpKind::UserSend:
|
|
case LiteOpKind::None:
|
|
default:
|
|
if (lite_send_callback_) {
|
|
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error);
|
|
lite_send_callback_ = nullptr;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
// 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();
|
|
|
|
// Chat note-buffer coordinator (BOTH variants). Pump: drain queued chat sends against verified notes and
|
|
// build/refill the buffer during idle. Then, full-node only, refresh the per-note counts via the governed
|
|
// z_listunspent scan (lite gets its counts from the refresh model, above). The pump goes first: a scan
|
|
// posted in the same frame then queues on worker_ behind the new z_sendmany, which locks its notes, so
|
|
// the scan's reply already reflects the spend. Both self-gate to no-ops unless chat is engaged; the pump
|
|
// serializes to one send outstanding at a time.
|
|
pumpChatNoteBuffer();
|
|
refreshChatNoteBudgetNode();
|
|
|
|
// One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
|
|
maybeRemindSeedBackup();
|
|
|
|
// One-time warning if the active wallet loaded empty while a sibling wallet file holds funds
|
|
// (a prior/unwitnessed salvage likely moved the coins into a wallet.<ts>.bak).
|
|
maybeWarnEmptyWalletWithFundedSiblings();
|
|
|
|
// One-time nudge if wallet.dat has bloated past the threshold (toast + clickable alert → consolidate).
|
|
maybeWarnLargeWallet();
|
|
|
|
// 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();
|
|
pumpWalletRestore();
|
|
// 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_ -= logic_dt_;
|
|
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();
|
|
}
|
|
|
|
// Apply a settled mining thread-count pick: one settings save + at most one miner change per gesture.
|
|
tickMiningThreadCommit();
|
|
|
|
// Mine-when-idle check (cheap on most frames: it gates itself to 1 Hz)
|
|
checkIdleMining();
|
|
|
|
// P8: Dedup rebuildAddressList — only rebuild once per frame
|
|
if (address_list_dirty_) {
|
|
address_list_dirty_ = false;
|
|
state_.rebuildAddressList();
|
|
}
|
|
|
|
using RefreshTimer = services::NetworkRefreshService::Timer;
|
|
|
|
// 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).
|
|
// effectivelySyncing() includes the post-sync settle window, so this also reverts to the normal
|
|
// per-tab cadence once that window elapses. Likewise when the window goes to or comes back from
|
|
// the background.
|
|
if (effectivelySyncing() != refresh_policy_syncing_ || window_background_ != refresh_policy_background_) {
|
|
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();
|
|
// The node is too busy to answer: send it nothing that needs cs_main (it would only queue behind the
|
|
// rest and hold one of its few RPC slots) until the canary gets through. pumpRpcBusyCanary() runs it.
|
|
pumpRpcBusyCanary();
|
|
pumpExternalDaemonLog();
|
|
const bool rpcBusy = rpc_busy_gate_->busy();
|
|
|
|
// Keep the "node starting / warming up / busy" overlay current between connect attempts and polls,
|
|
// unless something else has replaced the status (see init_status_title_, applyWarmupText).
|
|
if (ImGui::GetTime() >= status_refresh_at_) {
|
|
status_refresh_at_ = ImGui::GetTime() + 1.0;
|
|
if (state_.warming_up && !warmup_rpc_message_.empty() && connection_status_ == state_.warmup_status) {
|
|
applyWarmupText(warmup_rpc_message_);
|
|
} else if (state_.daemon_initializing && !state_.connected && !init_status_title_.empty() &&
|
|
connection_status_ == init_status_title_) {
|
|
applyDaemonInitStatus(init_status_busy_);
|
|
}
|
|
}
|
|
|
|
// 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_ && !rpcBusy && !window_background_) {
|
|
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.
|
|
if (user_initiated_rescan_) {
|
|
ui::Notifications::instance().success(TR("appx_blockchain_rescan_complete"));
|
|
user_initiated_rescan_ = false; // surfaced once; not for background rebuilds
|
|
}
|
|
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; };
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Surface an unexpected miner exit (crash / OOM-kill / external SIGKILL) once, and clear the stale
|
|
// running flag so the UI and auto-balance don't keep believing it's still hashing. (L-03, L-10)
|
|
if (xmrig_manager_ && state_.pool_mining.xmrig_running
|
|
&& xmrig_manager_->getState() == daemon::XmrigManager::State::Error) {
|
|
state_.pool_mining.xmrig_running = false;
|
|
state_.pool_mining.hashrate_10s = 0.0;
|
|
state_.pool_mining.hashrate_60s = 0.0;
|
|
state_.pool_mining.hashrate_15m = 0.0;
|
|
pool_starting_.store(false, std::memory_order_relaxed);
|
|
pool_threads_target_.store(0, std::memory_order_relaxed); // that miner is gone
|
|
const std::string err = xmrig_manager_->getLastError();
|
|
ui::Notifications::instance().error(err.empty() ? TR("appx_miner_stopped_unexpectedly")
|
|
: (std::string(TR("appx_miner_stopped_prefix")) + err));
|
|
}
|
|
|
|
// 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;
|
|
const auto xs = xmrig_manager_->getStats(); // getStats() now returns a locked copy (M-03)
|
|
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(TR("appx_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()) {
|
|
feedDaemonBusy(newOutput); // witness rebuild / startup replay, for the quit guard + stop loop
|
|
// 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 && user_initiated_rescan_) {
|
|
ui::Notifications::instance().success(TR("appx_blockchain_rescan_complete"));
|
|
user_initiated_rescan_ = false; // surfaced once; not for background rebuilds
|
|
}
|
|
// 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 && !rpcBusy && 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;
|
|
services::NetworkRefreshService::OperationStatusPollResult parsed;
|
|
const auto filter = services::NetworkRefreshService::buildOperationFilterParams(opids);
|
|
if (!filter) return [this](){ opid_poll_in_progress_ = false; };
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Send tab / Operation status");
|
|
// Filtered to our opids: the call takes cs_main + cs_wallet and builds a status object for
|
|
// every op it reports, and the daemon retains finished ops, so the unfiltered form grew
|
|
// with every send, shield and chat message. Its one argument is an ARRAY of ids,
|
|
// [[id, ...]]; the old ["opid"] attempt failed with "JSON value is not an array as
|
|
// expected" because params[0] was a string. If a daemon still answers the filter with an
|
|
// error, fall back to the unfiltered form for this connection rather than wedge sends on
|
|
// "Waiting for operation"; parseOperationStatusPoll() ignores ops we don't track.
|
|
if (!opid_filter_unsupported_.load(std::memory_order_relaxed)) {
|
|
try {
|
|
result = rpc->call("z_getoperationstatus", *filter);
|
|
} catch (const rpc::RpcError& e) {
|
|
if (e.kind() == rpc::RpcFailureKind::Warmup) throw;
|
|
result = rpc->call("z_getoperationstatus", json::array());
|
|
opid_filter_unsupported_.store(true, std::memory_order_relaxed);
|
|
}
|
|
} else {
|
|
result = rpc->call("z_getoperationstatus", json::array());
|
|
}
|
|
// Parse INSIDE the guard: a malformed/type-anomalous element must never abort the
|
|
// poll and leave opid_poll_in_progress_ stuck true for the whole connected session.
|
|
parsed = services::NetworkRefreshService::parseOperationStatusPoll(result, opids);
|
|
} catch (...) {
|
|
return [this](){ opid_poll_in_progress_ = false; };
|
|
}
|
|
return [this, parsed = std::move(parsed)]() mutable {
|
|
opid_poll_in_progress_ = false;
|
|
using Origin = services::NetworkRefreshService::OperationOrigin;
|
|
// Classify first: the moves below consume the parse.
|
|
const auto batch = services::NetworkRefreshService::planOperationBatch(parsed, op_origin_);
|
|
auto originOf = [this](const std::string& id) {
|
|
const auto it = op_origin_.find(id);
|
|
return it == op_origin_.end() ? Origin::User : it->second;
|
|
};
|
|
|
|
// 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);
|
|
// A chat op's record is its echo: tracking it would refresh the history every block and keep
|
|
// the balance poll reading minconf=1 until it settles.
|
|
if (originOf(opid) != Origin::Chat) {
|
|
send_txids_.insert(txid);
|
|
send_txid_heights_.emplace(txid, state_.sync.blocks);
|
|
}
|
|
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);
|
|
} else {
|
|
removePendingSendTransactions(terminalOpids, true);
|
|
}
|
|
// After the failure loop: maybeRetrySendForFeeGap reads the fee-gap marker there.
|
|
for (const auto& id : terminalOpids) {
|
|
op_origin_.erase(id);
|
|
send_feegap_retried_opids_.erase(id);
|
|
}
|
|
// A user op re-reads the wallet and forces the headline balance (once no user op is pending);
|
|
// the client auto-shield re-reads it unforced. A chat op only refreshes the chat note view: its
|
|
// tx moves the mempool signal, and its record is the echo.
|
|
if (batch.userSucceeded || batch.autoSucceeded) markWalletMutated(/*forceBalance=*/batch.userSucceeded);
|
|
else if (batch.walletTerminal) noteWalletMutation(); // failed or vanished: look again (conservative)
|
|
if (batch.chatTerminal) chat_note_rescan_requested_ = true; // the chat note view (payer, budget) changed
|
|
maybeFinishTransactionSendProgress();
|
|
};
|
|
});
|
|
}
|
|
// While the address scan that normally ends the send progress can't run (the background, a catch-up, the
|
|
// connect gate, a retry hold), end it once the operations are done: it keeps the window rendering.
|
|
if (send_progress_active_ && !addressScanCanRunNow()) maybeFinishTransactionSendProgress();
|
|
// C210: the "Keep in each address" consolidation run, if one is going (paced; one RPC at a time).
|
|
pumpConsolidation();
|
|
|
|
// 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_ && !post_bootstrap_probe_in_flight_ &&
|
|
std::chrono::steady_clock::now() >= post_bootstrap_probe_retry_at_ &&
|
|
rpcConnected && state_.connected && !rpcBusy &&
|
|
!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
|
|
if (post_bootstrap_probe_attempts_ == 0)
|
|
ui::Notifications::instance().info(TR("appx_bootstrap_complete_reconciling"));
|
|
++post_bootstrap_probe_attempts_;
|
|
post_bootstrap_probe_in_flight_ = true;
|
|
// Asynchronous (worker_): the callback runs on the UI thread a few frames later.
|
|
detectLowestAvailableBlockHeight([this](bool ok, int lowest, bool fullHistory) {
|
|
post_bootstrap_probe_in_flight_ = false;
|
|
if (shutting_down_ || !post_bootstrap_rescan_pending_) return;
|
|
if (ok) {
|
|
post_bootstrap_rescan_pending_ = false;
|
|
post_bootstrap_probe_attempts_ = 0;
|
|
if (!fullHistory) {
|
|
runtimeRescan(lowest); // bootstrapped/pruned: rescan from the snapshot base
|
|
} else {
|
|
rescanBlockchain(); // full-history node: the -rescan restart works
|
|
}
|
|
} else if (post_bootstrap_probe_attempts_ < 6) {
|
|
// Could not tell (busy node, a timeout, a reconnect). NOT the -rescan restart: on a bootstrapped
|
|
// node it fails. Keep the reconcile armed and probe again in 30 s.
|
|
post_bootstrap_probe_retry_at_ = std::chrono::steady_clock::now() + std::chrono::seconds(30);
|
|
} else {
|
|
post_bootstrap_rescan_pending_ = false;
|
|
post_bootstrap_probe_attempts_ = 0;
|
|
ui::Notifications::instance().warning(TR("appx_bootstrap_reconcile_probe_failed"));
|
|
}
|
|
});
|
|
}
|
|
|
|
// Per-category refresh with tab-aware intervals
|
|
// Skip when wallet is locked — same reason as above. Hold everything while the node is busy.
|
|
if (rpcConnected && !state_.isLocked() && rpcBusy) {
|
|
// nothing: the canary brings polling back (markImmediateRefresh) once the node answers
|
|
} else if (rpcConnected && !state_.isLocked()) {
|
|
const bool walletDataPage = currentPageNeedsWalletDataRefresh();
|
|
const services::ScanContext scan = scanContext();
|
|
// The connect gate just opened: what it held (the balance, the scans) is owed now. Under a held
|
|
// profile the marks are lost, but leaving it marks everything due again (applyRefreshPolicy).
|
|
if (scan_gate_.consumeOpenEdge(std::chrono::steady_clock::now(), state_.encryption_state_known,
|
|
state_.wallet_tx_count)) {
|
|
DEBUG_LOGF("[scan-gate] open\n");
|
|
network_refresh_.markDue(RefreshTimer::Core);
|
|
network_refresh_.markDue(RefreshTimer::Addresses);
|
|
network_refresh_.markDue(RefreshTimer::Transactions);
|
|
}
|
|
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_) {
|
|
// History: refreshTransactionData() decides what this cycle may scan (services::decideTxCycle):
|
|
// wallet-wide scans only as the scan budget allows, except explicit work (the first load, an
|
|
// invalidation, a new address); sends and other mutations get their rows through Light cycles.
|
|
// Held back by the budget, it asks again soon (a waiting stream must keep asking). The hard
|
|
// gates come first, so a held timer stays due instead of being eaten by a refused dispatch.
|
|
if (services::wholeWalletScanAllowed(scan) && network_refresh_.consumeDue(RefreshTimer::Transactions) &&
|
|
shouldRunWalletTransactionRefresh()) {
|
|
if (refreshTransactionData()) network_refresh_.retrySoon(RefreshTimer::Transactions, kScanRetrySec);
|
|
}
|
|
// 0-conf chat on the Chat page: a cheap txcount probe each tick (the user's chat poll rate), and the
|
|
// inbox scan when the wallet gained a transaction, as the scan budget admits it. A scan the budget held
|
|
// back asks again within kScanRetrySec (a waiting stream must keep asking). The normal harvest only
|
|
// re-scans on a new block. Self-gated (no-op without a chat identity / connection / on lite).
|
|
chat_fast_scan_accum_ += logic_dt_;
|
|
const float chatPoll = settings_ ? settings_->getChatPollRateSec() : 2.5f; // user-configurable
|
|
const float chatTick = chat_inbox_scan_wanted_ ? std::min(chatPoll, kScanRetrySec) : chatPoll;
|
|
if (chat_fast_scan_accum_ >= chatTick) {
|
|
chat_fast_scan_accum_ = 0.0f;
|
|
fastScanChatMemos();
|
|
}
|
|
const services::ScanTier addressTier =
|
|
services::planAddressScan(scan, addressListStale(), !address_balances_loaded_);
|
|
// What a held profile's timer can't bring (kSyncProfile's and the background's Addresses interval
|
|
// is 0): a stale list (list-only) and the session's first balances (while the node catches up).
|
|
// refreshAddressData() refuses while a pass runs or the retry hold does, and a pass that lands
|
|
// makes the list current and the balances loaded, so this stops by itself.
|
|
// The first balances while the node catches up wait for the first balance read to land: sent
|
|
// together they queue for the same cs_main and the second one times out.
|
|
const bool addressPassOwed = addressTier == services::ScanTier::ListOnly ||
|
|
(addressTier == services::ScanTier::Full && !services::wholeWalletScanAllowed(scan) &&
|
|
!scan_governor_.inFlight(services::ScanStream::Balance));
|
|
if (addressPassOwed) {
|
|
const auto steadyNow = std::chrono::steady_clock::now();
|
|
if (steadyNow >= address_owed_next_try_) {
|
|
address_owed_next_try_ = steadyNow + std::chrono::seconds(2);
|
|
refreshAddressData();
|
|
}
|
|
} else if (addressTier != services::ScanTier::None && network_refresh_.isDue(RefreshTimer::Addresses)) {
|
|
// Explicit need (a changed address set, a committed send or other mutation) refreshes now
|
|
// (list-only while the scans are held). The routine poll on a wallet page runs
|
|
// only when something it depends on changed (tip, mempool, a wallet mutation) or its safety
|
|
// sweep is due, and only as the scan budget allows; held back, it asks again soon.
|
|
bool retry = false;
|
|
if (addresses_dirty_) {
|
|
refreshAddressData();
|
|
} else if (walletDataPage && addressTier == services::ScanTier::Full) {
|
|
using services::ScanStream;
|
|
const double scanNow = scanClockNow();
|
|
const auto scanSig = currentScanSignals();
|
|
if (scan_governor_.needed(ScanStream::Addresses, scanSig, scanNow)) {
|
|
if (network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Addresses)) {
|
|
retry = true;
|
|
} else if (auto ticket = scan_governor_.tryAdmit(ScanStream::Addresses,
|
|
currentScanVisibility().addresses,
|
|
scanSig, scanNow)) {
|
|
refreshAddressData(ticket);
|
|
} else {
|
|
retry = true;
|
|
}
|
|
}
|
|
}
|
|
if (retry) network_refresh_.retrySoon(RefreshTimer::Addresses, kScanRetrySec);
|
|
else network_refresh_.reset(RefreshTimer::Addresses);
|
|
}
|
|
}
|
|
if (network_refresh_.consumeDue(RefreshTimer::Peers)) {
|
|
refreshPeerInfo();
|
|
}
|
|
} else if (rpcConnected && state_.isLocked()) {
|
|
// Connected but locked is still connected. This used to fall through to tryConnect(), which
|
|
// re-ran the whole connect sequence every Core tick while the lock screen sat idle: new
|
|
// sockets, getinfo + getwalletinfo, a forced address scan, a CoinGecko fetch and a wallet-index
|
|
// rewrite. Poll chain state only: that keeps the sync height current and refreshCoreData's
|
|
// 3-strike lost-connection check armed, so a node that exits while locked is still noticed and
|
|
// restarted. Wallet scans wait for the unlock (they would also hold cs_main ahead of the user's
|
|
// walletpassphrase), except the throttled balance while pool mining keeps the lock screen down.
|
|
const bool poolMiningActive = xmrig_manager_ && xmrig_manager_->isRunning();
|
|
if (!rpcBusy && !runtime_rescan_active_ && network_refresh_.consumeDue(RefreshTimer::Core)) {
|
|
refreshCoreData(/*allowBalance=*/poolMiningActive);
|
|
}
|
|
// Pool mining keeps the lock screen down, so the tabs stay usable while locked: keep peers
|
|
// current, and re-read the lock state at the slow peers cadence so an unlock done another way
|
|
// (walletpassphrase in the Console tab) is noticed.
|
|
if (poolMiningActive && !rpcBusy && network_refresh_.consumeDue(RefreshTimer::Peers)) {
|
|
refreshPeerInfo();
|
|
refreshEncryptionState();
|
|
}
|
|
} else if (!rpcBusy && network_refresh_.consumeDue(RefreshTimer::Core)) {
|
|
if (!connection_in_progress_ &&
|
|
wizard_phase_ == WizardPhase::None &&
|
|
!bootstrap_downloading_) {
|
|
tryConnect();
|
|
}
|
|
}
|
|
|
|
// Price refresh every 60 seconds (not in the background: it is due again as soon as the window is back)
|
|
if (!window_background_ && 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(std::string(TR("appx_theme_prefix")) + skins[cur].name);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keyboard shortcut: F5 to refresh (no key repeat: each refresh is a full wallet re-scan)
|
|
if (ImGui::IsKeyPressed(ImGuiKey_F5, false)) {
|
|
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();
|
|
if (settings_) {
|
|
// Persist the mode (and the opaque window low-spec implies) but not the effect prefs:
|
|
// applyVisualSettings overrides them at runtime, so turning low-spec off restores them.
|
|
settings_->setLowSpecMode(newLow);
|
|
if (newLow) settings_->setWindowOpacity(1.0f);
|
|
settings_->save();
|
|
applyVisualSettings();
|
|
} else {
|
|
ui::effects::setLowSpecMode(newLow);
|
|
}
|
|
ui::Notifications::instance().info(newLow ? TR("appx_low_spec_mode_enabled") : TR("appx_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 ? TR("appx_theme_effects_enabled") : TR("appx_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 ? TR("appx_simple_background_enabled") : TR("appx_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");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load (and recolor per theme) the DragonX header logo texture. Called at the top of render() — BEFORE
|
|
// the first-run-wizard / lock-screen paths — so every screen shows the mark. The SVG is recolored to the
|
|
// theme accent, so it re-rasterizes whenever the accent or the dark↔light variant changes.
|
|
void App::ensureLogoTexture()
|
|
{
|
|
const bool wantDark = ui::material::IsDarkTheme();
|
|
const ImU32 logoAccent = ui::material::Primary();
|
|
if (logo_loaded_ && wantDark == logo_is_dark_variant_ && logoAccent == logo_accent_)
|
|
return; // already current
|
|
logo_loaded_ = true;
|
|
logo_is_dark_variant_ = wantDark;
|
|
logo_accent_ = logoAccent;
|
|
// The SVG's white highlight (detail) stays white on dark skins, but darkens to the theme's on-surface
|
|
// colour on light skins so it doesn't wash out against a light card/background.
|
|
const ImU32 detailCol = wantDark ? IM_COL32(255, 255, 255, 255) : ui::material::OnSurface();
|
|
|
|
// ":drgx:" custom chat emoji — themed to the accent like the logo (re-rasterized on theme change).
|
|
if (drgx_emoji_tex_) util::DestroyTexture(drgx_emoji_tex_);
|
|
drgx_emoji_tex_ = 0; drgx_emoji_w_ = 0; drgx_emoji_h_ = 0;
|
|
util::LoadTextureFromSvg(embedded::kLogoDragonXSvg, 96, logoAccent, detailCol,
|
|
&drgx_emoji_tex_, &drgx_emoji_w_, &drgx_emoji_h_);
|
|
|
|
if (logo_tex_) util::DestroyTexture(logo_tex_); // free the previous texture before replacing
|
|
logo_tex_ = 0; logo_w_ = 0; logo_h_ = 0;
|
|
|
|
// Coin/currency icon (the big DragonX mark on the balance card) — the SAME themed SVG recolored to
|
|
// the accent, rasterized here so it changes with the skin too (done first, since the header block
|
|
// below early-returns on success). Falls back to the PNG coin icon if rasterization fails.
|
|
if (coin_logo_tex_) util::DestroyTexture(coin_logo_tex_);
|
|
coin_logo_tex_ = 0; coin_logo_w_ = 0; coin_logo_h_ = 0;
|
|
coin_logo_loaded_ = true;
|
|
if (!util::LoadTextureFromSvg(embedded::kLogoDragonXSvg, 128, logoAccent, detailCol,
|
|
&coin_logo_tex_, &coin_logo_w_, &coin_logo_h_)) {
|
|
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_))) {
|
|
std::string coinBasename = std::filesystem::path(coinFile).filename().string();
|
|
const auto* coinRes = resources::getEmbeddedResource(coinBasename);
|
|
if (coinRes && coinRes->data && coinRes->size > 0)
|
|
util::LoadTextureFromMemory(coinRes->data, coinRes->size, &coin_logo_tex_, &coin_logo_w_, &coin_logo_h_);
|
|
}
|
|
}
|
|
|
|
// The header / top-left / About branding is the ObsidianDragon PRODUCT logo — NOT the DragonX coin
|
|
// mark (that is coin_logo_tex_ / drgx_emoji_tex_ above). Resolve it below: active-skin override, else
|
|
// the ui.toml header-icon, else the bundled ObsidianDragon dark/light PNG (disk, then embedded).
|
|
|
|
// 1) theme-override logo from the 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
|
|
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.
|
|
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 {
|
|
std::string embeddedName = std::filesystem::path(logoPath).filename().string();
|
|
const auto* logoRes = resources::getEmbeddedResource(embeddedName);
|
|
if (!logoRes || !logoRes->data || logoRes->size == 0)
|
|
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());
|
|
}
|
|
}
|
|
}
|
|
|
|
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 node-file progress. Foreground draw list, so it shows over the wizard, the lock screen and
|
|
// the main UI alike (all of which early-return below).
|
|
renderStartupFilesChip();
|
|
|
|
// DragonX logo — load/recolor before the wizard/lock early-returns so every screen shows it.
|
|
ensureLogoTexture();
|
|
|
|
// First-run wizard gate — blocks all normal UI
|
|
if (wizard_phase_ != WizardPhase::None && wizard_phase_ != WizardPhase::Done) {
|
|
renderFirstRunWizard();
|
|
renderDaemonStopConfirm(); // a quit during the wizard still gets its "keep the node running?" prompt
|
|
ui::Notifications::instance().render();
|
|
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) {
|
|
char deletedMsg[160];
|
|
snprintf(deletedMsg, sizeof(deletedMsg), TR("appx_blockchain_data_deleted"), deletedN);
|
|
ui::Notifications::instance().success(deletedMsg);
|
|
}
|
|
if (pending_reinstall_node_busy_.exchange(false, std::memory_order_relaxed))
|
|
ui::Notifications::instance().warning(TR("appx_daemon_reinstall_node_busy"), 12.0f);
|
|
if (const int failed = pending_stop_failed_.exchange(0, std::memory_order_relaxed)) {
|
|
if ((failed & kStopFailedUndoRescan) && daemon_controller_) {
|
|
// The rescan/repair never started: disarm its flags so a later start doesn't run it unasked.
|
|
daemon_controller_->setRescanOnNextStart(false);
|
|
daemon_controller_->setZapOnNextStart(false);
|
|
state_.sync.rescanning = false;
|
|
state_.sync.rescan_status.clear();
|
|
rescan_confirmed_active_ = false;
|
|
user_initiated_rescan_ = false;
|
|
}
|
|
if ((failed & kStopFailedNotify) && !shutting_down_)
|
|
ui::Notifications::instance().warning(TR("appx_node_not_stopped"), 12.0f);
|
|
}
|
|
}
|
|
|
|
|
|
// 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;
|
|
};
|
|
// Scale by dp to match the rendered status-bar child height (renderStatusBar), so the reserved
|
|
// content strip stays in step with the bar at HiDPI instead of under-reserving.
|
|
float statusBarH = ui::schema::UI().window("components.status-bar").height * dp;
|
|
if (statusBarH <= 0.0f) statusBarH = 24.0f * dp; // 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;
|
|
sbStatus.chatUnreadCount = chatUnreadCount(); // unread badge on the Chat nav item (Q1)
|
|
|
|
// (DragonX logo is loaded at the top of render() via ensureLogoTexture().)
|
|
|
|
// (coin logo is loaded/themed at the top of render() via ensureLogoTexture().)
|
|
|
|
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.
|
|
// Recompute only when the tx list actually changed (last_tx_update is bumped on every tx refresh;
|
|
// size() catches same-second content changes). Otherwise this built two unordered_sets over the whole
|
|
// wallet tx list every frame just to size a badge.
|
|
if (state_.last_tx_update != sb_unconf_key_ts_ || state_.transactions.size() != sb_unconf_key_n_) {
|
|
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);
|
|
}
|
|
}
|
|
sb_unconf_count_ = static_cast<int>(unconfirmedTxids.size());
|
|
sb_unconf_key_ts_ = state_.last_tx_update;
|
|
sb_unconf_key_n_ = state_.transactions.size();
|
|
}
|
|
sbStatus.unconfirmedTxCount = sb_unconf_count_;
|
|
|
|
// Sidebar minimum height from ui.toml schema (DPI-scaled).
|
|
const float sbMinHeight = sbde("min-height", 360.0f);
|
|
|
|
// Save cursor Y before the sidebar so the content area (restored below) starts at the original row.
|
|
float preSidebarCursorY = ImGui::GetCursorPosY();
|
|
|
|
// Size the sidebar to span from its own top down to the status-bar top, so the nav panel centers
|
|
// within the TRUE visible area (equal top/bottom gaps). Do NOT derive it from contentH (inset by the
|
|
// content-area's edge-fade margins) and do NOT apply the legacy sidebar margin-top/-bottom (they are
|
|
// asymmetric, -12 / +40, and pushed the panel upward). Window-local reference (matches how the status
|
|
// bar is positioned — GetWindowPos/GetWindowSize, not GetMainViewport) so it is correct on every platform.
|
|
float sbWindowBottom = ImGui::GetWindowPos().y + ImGui::GetWindowSize().y;
|
|
float sbStatusTopY = sbWindowBottom - statusBarH - mainPadBot;
|
|
float sbChildTopY = ImGui::GetCursorScreenPos().y;
|
|
float sidebarH = std::max(sbMinHeight, sbStatusTopY - sbChildTopY);
|
|
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;
|
|
// Switching INTO the console → put the cursor in the command box (toggleable). Done here at the
|
|
// transition (not in the console render) because prev_page_ is updated below; the console renders
|
|
// later this same frame and consumes the one-shot request.
|
|
if ((current_page_ == ui::NavPage::Console || current_page_ == ui::NavPage::LiteConsole)
|
|
&& settings_ && settings_->getConsoleAutoFocus())
|
|
console_tab_.requestInputFocus();
|
|
// Leaving the Mining tab → cancel a running thread benchmark so the miner isn't abandoned at a
|
|
// benchmark step. (L-04)
|
|
if (prev_page_ == ui::NavPage::Mining && current_page_ != ui::NavPage::Mining)
|
|
ui::CancelMiningBenchmark(this);
|
|
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));
|
|
|
|
// Cap the content column to a readable max width and center it in wider windows. Every tab derives
|
|
// its cards/forms/tables from this child's width, so an uncapped fill stretches them edge-to-edge on
|
|
// wide/ultrawide displays. No-op below the cap (fills as before).
|
|
float caAvailW = ImGui::GetContentRegionAvail().x;
|
|
float caMaxW = ui::Layout::kContentMaxWidth();
|
|
float caChildW = 0.0f; // 0 -> fill remaining width (default, and whenever the window is below the cap)
|
|
if (caMaxW > 0.0f && caAvailW > caMaxW) {
|
|
caChildW = caMaxW;
|
|
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (caAvailW - caMaxW) * 0.5f);
|
|
}
|
|
|
|
// Capture content area screen position for edge fade mask (after any centering offset)
|
|
ImVec2 caScreenPos = ImGui::GetCursorScreenPos();
|
|
|
|
ImGui::BeginChild("##ContentArea", ImVec2(caChildW, contentH), false, contentFlags);
|
|
|
|
// Persistent node/RPC error banner — drawn first (before the edge-fade vertex capture below,
|
|
// so it stays fully opaque) and above every page / overlay in the content column. It renders
|
|
// nothing and consumes no space while the node is reachable.
|
|
renderNodeStatusBanner();
|
|
|
|
// 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_ += logic_dt_;
|
|
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_);
|
|
}
|
|
|
|
if (show_faq_) {
|
|
ui::RenderFaqDialog(this, &show_faq_);
|
|
}
|
|
|
|
// 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 command-reference popup (full-node RPC reference / lite backend verbs).
|
|
console_tab_.renderCommandsPopupModal(console_exec_.get());
|
|
|
|
// 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();
|
|
renderSwitchStopDaemonDialog();
|
|
renderDaemonStopConfirm();
|
|
renderBlockDbReindexDialog();
|
|
renderWalletRecoveredDialog();
|
|
renderEmptyWalletWarningDialog();
|
|
|
|
// 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::renderNodeStatusBanner()
|
|
{
|
|
namespace m = ui::material;
|
|
|
|
// Suppress during flows that legitimately have no connection, so the banner never contradicts
|
|
// an overlay the app is already showing: the first-run wizard (no daemon started yet), a
|
|
// wallet switch, an in-flight daemon restart, the screenshot sweep (forces demo state), and
|
|
// shutdown. tryConnect() sets connection_in_progress_ before the first render on normal
|
|
// startup, so the ordinary boot path is covered by the evaluator's own in-progress guard.
|
|
if (capture_mode_ || isShuttingDown()) return;
|
|
if (getWizardPhase() != WizardPhase::None) return;
|
|
if (wallet_switch_phase_.load() != 0) return;
|
|
if (daemon_restarting_.load()) return;
|
|
|
|
ui::NodeBannerInputs in;
|
|
in.lite = isLiteBuild();
|
|
in.connected = state_.connected;
|
|
in.warming_up = state_.warming_up;
|
|
in.daemon_initializing = state_.daemon_initializing;
|
|
in.connection_in_progress = connection_in_progress_;
|
|
in.using_embedded_daemon = isUsingEmbeddedDaemon();
|
|
in.has_daemon_controller = (daemon_controller_ != nullptr);
|
|
in.daemon_running = isEmbeddedDaemonRunning();
|
|
in.daemon_crash_count = daemon_controller_ ? daemon_controller_->crashCount() : 0;
|
|
in.connection_status = connection_status_;
|
|
in.daemon_last_error = daemon_controller_ ? daemon_controller_->lastError() : std::string();
|
|
in.lite_open_error = lite_open_error_;
|
|
in.tip_known = state_.sync.tip_known;
|
|
in.blocks = state_.sync.blocks;
|
|
const NodeBusyText busy = nodeBusyText();
|
|
in.node_busy = busy.busy;
|
|
in.node_busy_detail = busy.compact; // one line; the overlay and the quit prompt explain more
|
|
|
|
const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
|
|
if (!banner.show) return;
|
|
|
|
const auto& S = ui::schema::UI();
|
|
const float minH = S.drawElement("banners.node-status", "min-height").size;
|
|
const float baseH = S.drawElement("banners.node-status", "height").size;
|
|
|
|
// Fonts up-front so the banner height can never be shorter than the glyph row it vertically
|
|
// centers — otherwise the title/icon draw above the child's top clip rect and slice off (seen at
|
|
// HiDPI / large font scale, where the DPI-baked glyphs outgrow the schema height). Metrics scaled.
|
|
ImFont* icoFont = m::Type().iconSmall();
|
|
ImFont* txtFont = m::Type().body2();
|
|
const float glyphH = std::max(icoFont ? icoFont->LegacySize : 0.0f,
|
|
txtFont ? txtFont->LegacySize : 0.0f);
|
|
// Both operands must be in scaled px: vScale() already folds in dpiScale(), so the raw min-height
|
|
// floor needs the same dpiScale() or it under-clamps the banner at HiDPI.
|
|
float bannerH = std::max(minH * ui::Layout::dpiScale(), baseH * ui::Layout::vScale());
|
|
bannerH = std::max(bannerH, glyphH + ui::Layout::spacingSm() * 2.0f); // never shorter than text
|
|
|
|
const bool isError = (banner.severity == ui::NodeBannerSeverity::Error);
|
|
const ImU32 sevCol = isError ? m::Error() : m::Warning();
|
|
const ImU32 bgCol = m::WithAlphaF(sevCol, isError ? 0.20f : 0.15f);
|
|
|
|
// Translated headline for the reason; `detail` is the live status text (may be empty).
|
|
const char* title;
|
|
const char* icon;
|
|
switch (banner.reason) {
|
|
case ui::NodeBannerReason::DaemonCrashed:
|
|
title = TR("node_banner_crashed_title"); icon = ICON_MD_ERROR; break;
|
|
case ui::NodeBannerReason::LiteOpenFailed:
|
|
title = TR("node_banner_lite_open_failed"); icon = ICON_MD_ERROR; break;
|
|
case ui::NodeBannerReason::NoPeers:
|
|
title = TR("node_banner_no_peers_title"); icon = ICON_MD_CLOUD_OFF; break;
|
|
case ui::NodeBannerReason::NodeBusy:
|
|
title = busy.title.c_str(); icon = ICON_MD_HOURGLASS_EMPTY; break;
|
|
case ui::NodeBannerReason::FullNodeOffline:
|
|
default:
|
|
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
|
|
}
|
|
|
|
const char* actionLabel = nullptr;
|
|
if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect");
|
|
else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart");
|
|
|
|
const float padX = ui::Layout::spacingLg();
|
|
|
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(bgCol));
|
|
ImGui::BeginChild("##NodeStatusBanner",
|
|
ImVec2(ImGui::GetContentRegionAvail().x, bannerH), false,
|
|
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
|
const float winW = ImGui::GetWindowSize().x;
|
|
|
|
// Icon — centered on its own metrics (clamped so it never draws above the child's top).
|
|
ImGui::SetCursorPos(ImVec2(padX, std::max(0.0f, (bannerH - icoFont->LegacySize) * 0.5f)));
|
|
ImGui::PushFont(icoFont);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, sevCol);
|
|
ImGui::TextUnformatted(icon);
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopFont();
|
|
|
|
const float txtCy = std::max(0.0f, (bannerH - txtFont->LegacySize) * 0.5f);
|
|
|
|
// Right-aligned action button geometry (measured first so the detail text can be clipped to
|
|
// never run underneath it).
|
|
float btnW = 0.0f, btnH = 0.0f, actionReserve = 0.0f;
|
|
if (actionLabel) {
|
|
btnH = std::max(0.0f, bannerH - ui::Layout::spacingSm() * 2.0f);
|
|
btnW = ImGui::CalcTextSize(actionLabel).x + ui::Layout::spacingLg() * 1.6f;
|
|
actionReserve = btnW + padX + ui::Layout::spacingMd();
|
|
}
|
|
|
|
// Title.
|
|
ImGui::SameLine(0.0f, ui::Layout::spacingSm());
|
|
ImGui::SetCursorPosY(txtCy);
|
|
ImGui::PushFont(txtFont);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, sevCol);
|
|
ImGui::TextUnformatted(title);
|
|
ImGui::PopStyleColor();
|
|
|
|
// Detail (dim) on the same row, clipped with an ellipsis so it can't push the button off-screen.
|
|
if (!banner.detail.empty()) {
|
|
ImGui::SameLine(0.0f, ui::Layout::spacingSm());
|
|
ImGui::SetCursorPosY(txtCy);
|
|
const float budget = winW - ImGui::GetCursorPosX() - actionReserve;
|
|
if (budget > ImGui::CalcTextSize("W").x) {
|
|
// "· " + detail, ellipsized with "…" to the budget: the same string the old pop-one-char loop built
|
|
// (O(L^2) per frame on the ~1.5 KB Windows crash report), now valid UTF-8 at the cut. Memoized per
|
|
// (detail, budget, font size, FontId + rasterizer density), so an unchanged frame costs one compare.
|
|
static util::textfit::EllipsisFitCache s_detailFit; // UI thread only (render path)
|
|
ImFont* detailFont = ImGui::GetFont();
|
|
const std::string& shown = s_detailFit.get(
|
|
banner.detail, budget, detailFont->FontId, ImGui::GetFontSize(),
|
|
detailFont->CurrentRasterizerDensity,
|
|
[&](const std::string& detail) {
|
|
const std::string prefix = "\xC2\xB7 "; // "· "
|
|
const std::string ell = "\xE2\x80\xA6"; // "…"
|
|
std::string r = util::textfit::ellipsizeTail(prefix, detail, ell, budget,
|
|
[](const char* b, const char* e) { return ImGui::CalcTextSize(b, e).x; });
|
|
if (ImGui::CalcTextSize(r.c_str()).x > budget) {
|
|
// ellipsizeTail splits lines on the raw '\n' byte, but ImGui decodes a malformed UTF-8
|
|
// byte together with a following '\n' (e.g. CP-1252 bytes E9 0D 0A in the Windows daemon tail)
|
|
// and draws one joined, wider line. Fall back to the old whole-string loop, which measures
|
|
// exactly what is drawn (also reached when nothing fits; same result there).
|
|
std::string d = detail;
|
|
r = prefix + d;
|
|
if (ImGui::CalcTextSize(r.c_str()).x > budget) {
|
|
while (!d.empty() &&
|
|
ImGui::CalcTextSize((prefix + d + ell).c_str()).x > budget) {
|
|
d.pop_back();
|
|
while (!d.empty() &&
|
|
(static_cast<unsigned char>(d.back()) & 0xC0) == 0x80)
|
|
d.pop_back(); // drop the whole trailing UTF-8 code point
|
|
}
|
|
r = prefix + d + ell;
|
|
}
|
|
}
|
|
return r;
|
|
});
|
|
ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceMedium());
|
|
ImGui::TextUnformatted(shown.c_str());
|
|
ImGui::PopStyleColor();
|
|
}
|
|
}
|
|
ImGui::PopFont();
|
|
|
|
// Action button.
|
|
if (actionLabel) {
|
|
ImGui::SetCursorPos(ImVec2(winW - btnW - padX, std::max(0.0f, (bannerH - btnH) * 0.5f)));
|
|
if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) {
|
|
if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon();
|
|
else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect();
|
|
}
|
|
}
|
|
|
|
ImGui::EndChild();
|
|
ImGui::PopStyleColor();
|
|
}
|
|
|
|
void App::renderAlertHistoryPanel()
|
|
{
|
|
namespace m = ui::material;
|
|
const float dp = ui::Layout::dpiScale();
|
|
auto& notes = ui::Notifications::instance();
|
|
const auto& hist = notes.history();
|
|
const float innerW = ImGui::GetContentRegionAvail().x;
|
|
const float padX = 8.0f * dp;
|
|
const float padY = 8.0f * dp;
|
|
|
|
ImFont* icoF = m::Type().iconSmall();
|
|
ImFont* txtF = m::Type().caption();
|
|
|
|
ImGui::Dummy(ImVec2(0.0f, padY)); // breathing room above the content
|
|
|
|
// Header: "Recent alerts" on the left, a Clear-all icon button on the right.
|
|
ImGui::SetCursorPosX(padX);
|
|
ImGui::PushFont(txtF);
|
|
ImGui::TextDisabled("%s", TR("alerts_recent"));
|
|
ImGui::PopFont();
|
|
if (!hist.empty()) {
|
|
const float clrW = icoF->LegacySize + 8.0f * dp;
|
|
ImGui::SameLine();
|
|
ImGui::SetCursorPosX(innerW - clrW);
|
|
m::IconButtonStyle cst;
|
|
cst.color = m::OnSurfaceMedium();
|
|
cst.hoverColor = m::OnSurface();
|
|
cst.hoverBg = m::StateHover();
|
|
cst.bgRounding = 4.0f * dp;
|
|
cst.tooltip = TR("alerts_clear");
|
|
if (m::IconButton("##ClearAlerts", ICON_MD_CLEAR_ALL, icoF,
|
|
ImVec2(clrW, icoF->LegacySize + 4.0f * dp), cst)) {
|
|
notes.clearHistory();
|
|
alerts_seen_total_ = notes.totalPushed();
|
|
}
|
|
}
|
|
ImGui::Separator();
|
|
|
|
if (hist.empty()) {
|
|
ImGui::SetCursorPosX(padX);
|
|
ImGui::PushFont(txtF);
|
|
ImGui::TextDisabled("%s", TR("alerts_none"));
|
|
ImGui::PopFont();
|
|
ImGui::Dummy(ImVec2(0.0f, padY)); // breathing room below the content
|
|
return;
|
|
}
|
|
|
|
// Scrollable list, newest first. Measure the TRUE content height so wrapped (multi-line) messages
|
|
// and optional action links aren't clipped by an under-estimate; cap so a busy session scrolls
|
|
// inside the panel instead of blowing past the popup's max height.
|
|
const float msgWrapW = std::max(40.0f * dp, innerW - 2.0f * padX - icoF->LegacySize - 6.0f * dp);
|
|
float contentH = 0.0f;
|
|
for (const auto& a : hist) {
|
|
const float msgH = txtF->CalcTextSizeA(txtF->LegacySize, FLT_MAX, msgWrapW, a.message.c_str()).y;
|
|
contentH += std::max(msgH, static_cast<float>(icoF->LegacySize)); // icon + wrapped message
|
|
contentH += txtF->LegacySize; // relative-age line
|
|
if (a.onClick && !a.actionHint.empty()) contentH += txtF->LegacySize; // action-link line
|
|
contentH += 8.0f * dp; // inter-entry spacing
|
|
}
|
|
const float listH = std::min(300.0f * dp, contentH);
|
|
ImGui::BeginChild("##AlertRows", ImVec2(0, listH), false);
|
|
int idx = 0;
|
|
for (auto it = hist.rbegin(); it != hist.rend(); ++it, ++idx) {
|
|
const ui::AlertRecord& a = *it;
|
|
ImU32 col; const char* icon;
|
|
switch (a.type) {
|
|
case ui::NotificationType::Success: col = m::Success(); icon = ICON_MD_CHECK_CIRCLE; break;
|
|
case ui::NotificationType::Warning: col = m::Warning(); icon = ICON_MD_WARNING; break;
|
|
case ui::NotificationType::Error: col = m::Error(); icon = ICON_MD_ERROR; break;
|
|
case ui::NotificationType::Info:
|
|
default: col = m::Primary(); icon = ICON_MD_INFO; break;
|
|
}
|
|
ImGui::PushID(idx);
|
|
// Icon + message (message wraps in the remaining width).
|
|
ImGui::SetCursorPosX(padX);
|
|
ImGui::PushFont(icoF);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, col);
|
|
ImGui::TextUnformatted(icon);
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopFont();
|
|
ImGui::SameLine(0.0f, 6.0f * dp);
|
|
ImGui::PushFont(txtF);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurface());
|
|
ImGui::PushTextWrapPos(innerW - padX);
|
|
ImGui::TextWrapped("%s", a.message.c_str());
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::PopStyleColor();
|
|
// Optional clickable action (accent link), directly under the message so it stays prominent.
|
|
if (a.onClick && !a.actionHint.empty()) {
|
|
ImGui::SetCursorPosX(padX + icoF->LegacySize + 6.0f * dp);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, m::Primary());
|
|
ImGui::TextUnformatted(a.actionHint.c_str());
|
|
ImGui::PopStyleColor();
|
|
if (ImGui::IsItemHovered()) {
|
|
const ImVec2 lmn = ImGui::GetItemRectMin(), lmx = ImGui::GetItemRectMax();
|
|
ImGui::GetWindowDrawList()->AddLine(ImVec2(lmn.x, lmx.y), ImVec2(lmx.x, lmx.y), m::Primary());
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
}
|
|
if (ImGui::IsItemClicked()) { a.onClick(); ImGui::CloseCurrentPopup(); }
|
|
}
|
|
// Relative age, dim, indented under the message.
|
|
ImGui::SetCursorPosX(padX + icoF->LegacySize + 6.0f * dp);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceDisabled());
|
|
ImGui::TextUnformatted(util::formatTimeAgoShort(a.epoch).c_str());
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopFont();
|
|
ImGui::PopID();
|
|
ImGui::Spacing();
|
|
}
|
|
ImGui::EndChild();
|
|
ImGui::Dummy(ImVec2(0.0f, padY)); // breathing room below the content
|
|
}
|
|
|
|
void App::renderStatusBar()
|
|
{
|
|
// Status bar layout from unified UI schema
|
|
const auto& S = ui::schema::UI();
|
|
const auto& sbWin = S.window("components.status-bar");
|
|
// Schema values are logical px; the fonts/icons drawn inside are DPI-baked, so the box and its
|
|
// gaps must be scaled by the same factor or they clip the text at HiDPI / font_scale > 1.
|
|
const float dp = ui::Layout::dpiScale();
|
|
const float sbHeight = sbWin.height * dp;
|
|
const float sbPadX = sbWin.padding[0] * dp;
|
|
const float sbPadY = sbWin.padding[1] * dp;
|
|
const float sbIconTextGap = S.drawElement("components.status-bar", "icon-text-gap").size * dp;
|
|
const float sbSectionGap = S.drawElement("components.status-bar", "section-gap").size * dp;
|
|
const float sbSeparatorGap = S.drawElement("components.status-bar", "separator-gap").size * dp;
|
|
|
|
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 (rpc_busy_gate_->busy()) {
|
|
// The node holds back our requests (a long operation holds its cs_main): data may be stale.
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Warning()), "%s", TR("sb_node_busy"));
|
|
} 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);
|
|
}
|
|
|
|
// C210: a consolidation run keeps going after its dialog closes. A static chip (no animation, no frames of
|
|
// its own); clicking it reopens the run view.
|
|
if (consolidation_.active()) {
|
|
const services::RunProgress p = consolidation_.progress();
|
|
const ImVec4 col(0.6f, 0.8f, 1.0f, 1.0f);
|
|
ImGui::SameLine(0, sbSectionGap);
|
|
ImGui::TextDisabled("|");
|
|
ImGui::SameLine(0, sbSeparatorGap);
|
|
ImGui::BeginGroup();
|
|
ImGui::PushFont(ui::material::Type().iconSmall());
|
|
ImGui::TextColored(col, ICON_MD_CALL_MERGE);
|
|
ImGui::PopFont();
|
|
ImGui::SameLine(0, sbIconTextGap);
|
|
ImGui::TextColored(col, TR("merge_run_chip_fmt"), p.batchesSubmitted, std::max(1, p.batchesEstimated));
|
|
ImGui::EndGroup();
|
|
if (ImGui::IsItemHovered()) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
if (!p.stopping) {
|
|
if (const char* k = services::runWaitKey(p.wait)) ui::material::Tooltip("%s", TR(k));
|
|
}
|
|
}
|
|
if (ImGui::IsItemClicked()) ui::ShieldDialog::showConsolidate(); // reopens the run view
|
|
}
|
|
|
|
// 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.
|
|
{
|
|
// Where the left-side chain ended, so the right cluster never SameLine()s backward onto it.
|
|
const float leftEndX = ImGui::GetCursorPosX();
|
|
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;
|
|
float occupiedX = versionX; // leftmost X used by the version + connection status so far
|
|
// The left of the bar already shows the warmup / "node busy" status; don't print it twice.
|
|
const bool shownOnLeft = (state_.warming_up || state_.daemon_initializing) &&
|
|
connection_status_ == state_.warmup_status;
|
|
if (!connection_status_.empty() && connection_status_ != "Connected" &&
|
|
connection_status_ != TR("connected") && !shownOnLeft) {
|
|
// During a post-repair rescan the raw status is a scary "RPC request failed: Timeout" — show a
|
|
// calm line instead (the rescan legitimately can't answer RPC yet).
|
|
const std::string statusBase = post_recovery_rescan_ ? std::string(TR("sb_finishing_repair"))
|
|
: connection_status_;
|
|
// Middle-ellipsize to the space between the left chain and the version so a long status
|
|
// (e.g. "Wallet needs recovery — see the prompt") can't push off-screen or overprint the
|
|
// left chain; then clamp its start so it never crosses left of where the chain ended.
|
|
float availW = versionX - leftEndX - gap * 2.0f;
|
|
if (availW < 24.0f * dp) availW = 24.0f * dp;
|
|
ImFont* stFont = ImGui::GetFont();
|
|
std::string statusShown = ui::material::TruncateToWidth(
|
|
statusBase, stFont, stFont->LegacySize, availW);
|
|
float statusW = ImGui::CalcTextSize(statusShown.c_str()).x;
|
|
float statusX = versionX - statusW - gap;
|
|
if (statusX < leftEndX + gap) statusX = leftEndX + gap;
|
|
ImGui::SameLine(statusX);
|
|
if (wallet_auto_recovered_ && !post_recovery_rescan_) {
|
|
// Actionable re-entry: a full-opacity accent chip that reopens the recovery dialog.
|
|
// Dismiss is non-destructive, so this is the guaranteed way back to the prompt.
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning());
|
|
ImGui::TextUnformatted(statusShown.c_str());
|
|
ImGui::PopStyleColor();
|
|
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
if (ImGui::IsItemClicked() && recovery_phase_ != RecoveryPhase::Working) {
|
|
show_wallet_recovered_dialog_ = true; // reopen the (non-destructively dismissed) prompt
|
|
recovery_phase_ = RecoveryPhase::Offer;
|
|
}
|
|
} else {
|
|
ImGui::TextDisabled("%s", statusShown.c_str());
|
|
}
|
|
occupiedX = statusX;
|
|
} else if (const std::string ds = getDaemonStatus();
|
|
!ds.empty() && ds.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);
|
|
occupiedX = statusX;
|
|
}
|
|
|
|
// Chat note-buffer status — only while the Chat tab is active. Sits left of the version/connection
|
|
// block. Surfaces the buffer filling/draining (and doubles as a diagnostic for send readiness).
|
|
if (current_page_ == ui::NavPage::Chat) {
|
|
const std::string cb = chatBufferStatusText();
|
|
if (!cb.empty()) {
|
|
float cbW = ImGui::CalcTextSize(cb.c_str()).x;
|
|
float cbX = occupiedX - cbW - gap;
|
|
ImGui::SameLine(cbX);
|
|
ImGui::TextUnformatted(cb.c_str());
|
|
occupiedX = cbX;
|
|
}
|
|
}
|
|
|
|
// Alert-history bell — leftmost item of the right cluster. Opens a panel of recent alerts,
|
|
// including ones whose toast already faded; an unread dot marks alerts that arrived since
|
|
// the panel was last opened.
|
|
{
|
|
auto& notes = ui::Notifications::instance();
|
|
ImFont* bellFont = ui::material::Type().iconSmall();
|
|
const bool anyHist = notes.hasHistory();
|
|
const char* bellGlyph = anyHist ? ICON_MD_NOTIFICATIONS : ICON_MD_NOTIFICATIONS_NONE;
|
|
|
|
ImGui::PushFont(bellFont);
|
|
const float glyphW = ImGui::CalcTextSize(bellGlyph).x;
|
|
ImGui::PopFont();
|
|
const float bellW = glyphW + 10.0f * dp;
|
|
const float bellH = bellFont->LegacySize + 4.0f * dp;
|
|
const float bellX = occupiedX - bellW - gap;
|
|
|
|
ImGui::SameLine(bellX);
|
|
ui::material::IconButtonStyle st;
|
|
st.color = ui::material::OnSurfaceMedium();
|
|
st.hoverColor = ui::material::OnSurface();
|
|
st.hoverBg = ui::material::StateHover();
|
|
st.bgRounding = 4.0f * dp;
|
|
st.tooltip = TR("alerts_history_tooltip");
|
|
const bool clicked = ui::material::IconButton("##AlertBell", bellGlyph, bellFont,
|
|
ImVec2(bellW, bellH), st);
|
|
const ImVec2 bellMin = ImGui::GetItemRectMin();
|
|
const ImVec2 bellMax = ImGui::GetItemRectMax();
|
|
|
|
// Unread dot: alerts pushed since the panel was last opened, coloured by the most
|
|
// severe unseen alert. totalPushed() is monotonic, so this survives capping/clearing.
|
|
const std::uint64_t unseen = notes.totalPushed() - alerts_seen_total_;
|
|
if (unseen > 0 && anyHist) {
|
|
const auto& h = notes.history();
|
|
size_t scan = (unseen < h.size()) ? static_cast<size_t>(unseen) : h.size();
|
|
bool anyErr = false, anyWarn = false;
|
|
for (size_t i = 0; i < scan; ++i) {
|
|
auto t = h[h.size() - 1 - i].type;
|
|
if (t == ui::NotificationType::Error) { anyErr = true; break; }
|
|
if (t == ui::NotificationType::Warning) anyWarn = true;
|
|
}
|
|
ImU32 dotCol = anyErr ? ui::material::Error()
|
|
: anyWarn ? ui::material::Warning()
|
|
: ui::material::Primary();
|
|
const float r = 3.0f * dp;
|
|
ImGui::GetWindowDrawList()->AddCircleFilled(
|
|
ImVec2(bellMax.x - r, bellMin.y + r), r, dotCol);
|
|
}
|
|
|
|
if (clicked) {
|
|
alerts_seen_total_ = notes.totalPushed(); // mark everything currently shown as seen
|
|
ImGui::OpenPopup("##AlertHistoryPopup");
|
|
}
|
|
|
|
// The bell sits near the window's bottom-right, so anchor the popup's bottom-RIGHT
|
|
// corner at the bell's right edge (pivot (1,1)) — it then grows LEFT over the canvas and
|
|
// UP from the status bar. A left pivot would push a 320px panel off the right edge (and
|
|
// an explicit SetNextWindowPos pivot skips ImGui's on-screen clamp, so it would overflow).
|
|
ImGui::SetNextWindowPos(ImVec2(bellMax.x, bellMin.y - 4.0f * dp),
|
|
ImGuiCond_Always, ImVec2(1.0f, 1.0f));
|
|
const float panelW = 320.0f * dp;
|
|
ImGui::SetNextWindowSizeConstraints(ImVec2(panelW, 0), ImVec2(panelW, 360.0f * dp));
|
|
if (ImGui::BeginPopup("##AlertHistoryPopup")) {
|
|
renderAlertHistoryPanel();
|
|
ImGui::EndPopup();
|
|
}
|
|
|
|
occupiedX = bellX;
|
|
}
|
|
|
|
// Help (?) — sits just left of the alert bell, on every platform. A plain, subtle question
|
|
// mark (no circle) that opens the FAQ.
|
|
{
|
|
ImFont* helpFont = ui::material::Type().iconSmall();
|
|
ImGui::PushFont(helpFont);
|
|
const float helpGlyphW = ImGui::CalcTextSize(ICON_MD_QUESTION_MARK).x;
|
|
ImGui::PopFont();
|
|
const float helpW = helpGlyphW + 10.0f * dp;
|
|
const float helpH = helpFont->LegacySize + 4.0f * dp;
|
|
const float helpX = occupiedX - helpW - gap;
|
|
|
|
ImGui::SameLine(helpX);
|
|
ui::material::IconButtonStyle hst;
|
|
hst.color = ui::material::OnSurfaceMedium();
|
|
hst.hoverColor = ui::material::OnSurface();
|
|
hst.hoverBg = ui::material::StateHover();
|
|
hst.bgRounding = 4.0f * dp;
|
|
hst.tooltip = TR("faq_open_tooltip");
|
|
if (ui::material::IconButton("##HelpFaq", ICON_MD_QUESTION_MARK, helpFont,
|
|
ImVec2(helpW, helpH), hst)) {
|
|
show_faq_ = true;
|
|
}
|
|
occupiedX = helpX;
|
|
}
|
|
|
|
// 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 (ensureLogoTexture re-rasterizes the
|
|
// SVG for the new theme). Free the current texture first so a theme switch doesn't leak it.
|
|
if (logo_tex_) util::DestroyTexture(logo_tex_);
|
|
logo_loaded_ = false;
|
|
logo_tex_ = 0;
|
|
logo_w_ = 0;
|
|
logo_h_ = 0;
|
|
coin_logo_loaded_ = false;
|
|
if (coin_logo_tex_) util::DestroyTexture(coin_logo_tex_);
|
|
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 dp = ui::Layout::dpiScale();
|
|
const float btnW = 170.0f * dp;
|
|
|
|
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 * dp);
|
|
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(TR("appx_creating_your_wallet"));
|
|
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(TR("appx_create_failed_prefix")) + 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(TR("appx_back_up_seed_phrase_title"));
|
|
ImGui::PopFont();
|
|
ImGui::Spacing();
|
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f * dp);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
|
ImGui::TextUnformatted(TR("appx_seed_backup_warning"));
|
|
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 * dp);
|
|
}
|
|
ImGui::Spacing();
|
|
char bday[80];
|
|
snprintf(bday, sizeof(bday), TR("appx_birthday_block_height"), birthday);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
|
|
ImGui::TextUnformatted(bday);
|
|
ImGui::PopStyleColor();
|
|
ImGui::Spacing(); ImGui::Spacing();
|
|
|
|
if (ui::material::TactileButton(TR("appx_ive_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(TR("appx_copy"), ImVec2(80 * dp, 0))) copySecretToClipboard(seed);
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_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(TR("appx_seed_not_backed_up_warning"));
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopTextWrapPos();
|
|
}
|
|
} else if (step == 2) {
|
|
// ── Verify: tap the words in order ──────────────────────────────────────
|
|
ImGui::PushFont(ui::material::Type().subtitle1());
|
|
ImGui::TextUnformatted(TR("appx_confirm_your_backup"));
|
|
ImGui::PopFont();
|
|
ImGui::Spacing();
|
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
|
|
ImGui::TextUnformatted(TR("appx_tap_words_in_order"));
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::Spacing();
|
|
|
|
ImGui::TextDisabled(TR("appx_progress_n_of_n"), progress, (int)words.size());
|
|
if (ImGui::GetTime() < wrongFlashUntil) {
|
|
ImGui::SameLine();
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
|
ImGui::TextUnformatted(TR("appx_not_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 * dp, 0));
|
|
ImGui::EndDisabled();
|
|
} else if (ui::material::TactileButton(chips[i].first.c_str(), ImVec2(125 * dp, 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(TR("appx_done"), ImVec2(btnW, 0))) {
|
|
ui::Notifications::instance().success(TR("appx_wallet_created_and_backed_up"), 6.0f);
|
|
finish();
|
|
}
|
|
if (!verified) ImGui::EndDisabled();
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(TR("appx_back"), ImVec2(80, 0))) { skipConfirm = false; step = 1; }
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_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(TR("appx_seed_not_backed_up_warning"));
|
|
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(TR("appx_restoring_your_wallet"));
|
|
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 * dp, ImGui::GetTextLineHeight() * 3.2f));
|
|
ImGui::Spacing();
|
|
ImGui::TextUnformatted(TR("lite_restore_birthday_label"));
|
|
ImGui::SetNextItemWidth(160.0f * dp);
|
|
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();
|
|
|
|
// Normalize the entered seed (trim, fold exotic Unicode whitespace like NBSP to plain
|
|
// spaces) so the word count and the phrase we submit agree regardless of paste source.
|
|
std::string seedTrim = util::normalizeSeedPhrase(restoreSeed);
|
|
|
|
// Require a COMPLETE 24-word phrase before enabling Restore. The SDXL backend only
|
|
// accepts 24-word / 32-byte-entropy seeds; a shorter valid-BIP39 phrase (12/15/18/21)
|
|
// panics it uncaught across the restore FFI, so it must be refused here (matches the
|
|
// Settings restore gate — both go through util::isCompleteRecoveryPhrase).
|
|
int seedWords = util::seedPhraseWordCount(seedTrim);
|
|
bool seedLenOk = util::isCompleteRecoveryPhrase(seedWords);
|
|
if (!seedTrim.empty() && !seedLenOk) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::Warning()));
|
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
|
|
char recoveryWordsMsg[96];
|
|
snprintf(recoveryWordsMsg, sizeof(recoveryWordsMsg),
|
|
TR("appx_recovery_phrase_word_count"), seedWords);
|
|
ImGui::TextUnformatted(recoveryWordsMsg);
|
|
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; // normalized: NBSP-glued pastes restore correctly
|
|
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(TR("appx_could_not_start_restore"))
|
|
: lite_wallet_->lastOpenError();
|
|
}
|
|
}
|
|
ImGui::EndDisabled();
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(TR("appx_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)) {
|
|
const float dp = ui::Layout::dpiScale();
|
|
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 * dp);
|
|
const bool entered = ImGui::InputText("##LiteUnlockPassModal", pass, sizeof(pass),
|
|
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_EnterReturnsTrue);
|
|
ImGui::Spacing();
|
|
const float btnW = 130.0f * dp;
|
|
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);
|
|
|
|
// Numeric entry first — the slider sits below it.
|
|
ImGui::SetNextItemWidth(availW);
|
|
ImGui::InputText("##importscanheight", buf, bufSize, ImGuiInputTextFlags_CharsDecimal);
|
|
cur = buf[0] ? std::atoi(buf) : 0; // reflect a just-typed value in the slider below
|
|
if (cur < 0) cur = 0;
|
|
|
|
if (tipHeight > 0) {
|
|
ImGui::Spacing();
|
|
const float barW = availW; // slider spans the panel interior, below 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).
|
|
// OnSurface-based (not pure white) so the track/label/thumb stay visible on a light-theme glass
|
|
// panel, which goes opaque white.
|
|
dl->AddRectFilled(barMin, barMax, m::WithAlpha(m::OnSurface(), enabled ? 30 : 14), 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),
|
|
m::WithAlpha(m::OnSurface(), enabled ? 200 : 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, m::WithAlpha(m::OnSurface(), enabled ? 215 : 90), 24);
|
|
dl->AddCircle(ImVec2(thumbCenterX, thumbY), thumbR, m::WithAlpha(m::OnSurface(), enabled ? 130 : 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);
|
|
}
|
|
}
|
|
|
|
ImGui::EndDisabled();
|
|
}
|
|
|
|
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;
|
|
// Wide enough that the one-line care header under the heading doesn't wrap; the viewing-key note
|
|
// is longer, so that mode gets a wider card.
|
|
ov.cardWidth = viewMode ? 760.0f : 640.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 header — same warning icon + color for both modes (the viewing dialog is wider
|
|
// so its longer watch-only note stays on one line, matching the private-key warning).
|
|
m::DialogWarningHeader(viewMode ? TR("import_viewkey_note") : 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 (no "Key" label — the dialog heading already gives the context); the reveal
|
|
// (eye) toggle sits frameless flush at its right edge, with labelled Paste / Clear buttons beneath.
|
|
// Hovering Paste previews the clipboard INSIDE the field (masked with '*' unless the key is
|
|
// revealed) — like the send tab — so the user can verify before pasting.
|
|
const float fieldH = ImGui::GetFrameHeight();
|
|
const ImVec2 iconSz(fieldH, fieldH);
|
|
const float eyeGap = ui::Layout::spacingSm(); // breathing room between the text and the eye button
|
|
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - fieldH - eyeGap);
|
|
ImGuiInputTextFlags flags = import_key_reveal_ ? 0 : ImGuiInputTextFlags_Password;
|
|
ImGui::InputText("##importkey", import_key_input_, sizeof(import_key_input_), flags);
|
|
const ImVec2 fieldMin = ImGui::GetItemRectMin(); // anchor for the paste-preview overlay
|
|
ImGui::SameLine(0, eyeGap);
|
|
m::IconButtonStyle eyeSt;
|
|
eyeSt.hoverBg = m::WithAlpha(m::OnSurface(), 30);
|
|
eyeSt.bgRounding = 4.0f * dp;
|
|
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_;
|
|
|
|
// Paste / Clear — centered, slightly larger labelled buttons.
|
|
const float actBtnW = 104.0f * dp;
|
|
const float actTotal = actBtnW * 2.0f + ImGui::GetStyle().ItemSpacing.x;
|
|
const float actOff = (ImGui::GetContentRegionAvail().x - actTotal) * 0.5f;
|
|
if (actOff > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + actOff);
|
|
const bool pasteClicked = m::TactileButton(TR("paste"), ImVec2(actBtnW, 0));
|
|
const bool pasteHovered = ImGui::IsItemHovered();
|
|
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();
|
|
if (m::TactileButton(TR("clear"), ImVec2(actBtnW, 0))) sodium_memzero(import_key_input_, sizeof(import_key_input_));
|
|
|
|
// While Paste is hovered and the field is empty, preview the trimmed clipboard INSIDE the field —
|
|
// masked with '*' unless the key is revealed — coloured by how it would classify (green ok / amber
|
|
// wrong-type / red unrecognized). Drawn via the draw list only: it verifies the key WITHOUT adding
|
|
// a layout row, since a hover-toggled row would grow the centered dialog and shift the button out
|
|
// from under the cursor (hover flicker).
|
|
if (pasteHovered && import_key_input_[0] == '\0') {
|
|
const char* clip = ImGui::GetClipboardText();
|
|
if (clip && clip[0]) {
|
|
std::string t(clip);
|
|
while (!t.empty() && (t.front()==' '||t.front()=='\t'||t.front()=='\n'||t.front()=='\r')) t.erase(t.begin());
|
|
while (!t.empty() && (t.back()==' '||t.back()=='\t'||t.back()=='\n'||t.back()=='\r')) t.pop_back();
|
|
if (!t.empty()) {
|
|
const bool pView = services::WalletSecurityController::isViewingKey(t);
|
|
const bool pSpend = services::WalletSecurityController::isRecognizedPrivateKey(t);
|
|
const bool pOk = viewMode ? pView : pSpend;
|
|
const bool pWrong = viewMode ? pSpend : pView;
|
|
ImVec4 col = pOk ? m::SuccessVec4()
|
|
: pWrong ? ImVec4(0.8f, 0.6f, 0.0f, 1.0f)
|
|
: m::ErrorVec4();
|
|
col.w = 0.6f; // ghost (uncommitted) preview
|
|
std::string shown = import_key_reveal_ ? t : std::string(std::min(t.size(), (size_t)56), '*');
|
|
if (shown.size() > 48) shown.resize(48); // fits the field before the flush eye button
|
|
const ImVec2 tp(fieldMin.x + ImGui::GetStyle().FramePadding.x,
|
|
fieldMin.y + ImGui::GetStyle().FramePadding.y);
|
|
ImGui::GetWindowDrawList()->AddText(tp, ImGui::ColorConvertFloat4ToU32(col), shown.c_str());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Trimmed field content drives the type indicator, the Import/Sweep guard, and the scan panel — NOT
|
|
// the hover preview, so hovering can neither shift the layout nor enable the action button.
|
|
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
|
|
|
|
// Auto-sized glass sub-section (combo-safe local splitter; see GlassSectionScope).
|
|
m::GlassSectionScope section;
|
|
const float availW = section.interiorWidth();
|
|
|
|
// 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);
|
|
|
|
// Only note why the control is inert for a transparent key; the generic "0 = rescan from the
|
|
// start" hint was dropped as self-evident (0 is the field's default).
|
|
if (transparentKey)
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", TR("import_scan_transparent"));
|
|
}
|
|
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();
|
|
|
|
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;
|
|
bool doAction = false, doClose = false;
|
|
m::DialogActionFooter(sweepMode ? TR("sweep_button") : TR("import_key_import"), canAct,
|
|
TR("close"), doAction, doClose);
|
|
if (doClose) show_import_key_ = false;
|
|
if (doAction) {
|
|
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;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
{
|
|
namespace m = ui::material;
|
|
float dp = ui::Layout::dpiScale();
|
|
|
|
m::OverlayDialogSpec ov;
|
|
ov.title = TR("export_private_key");
|
|
ov.p_open = &show_export_key_;
|
|
ov.style = m::OverlayStyle::BlurFloat;
|
|
ov.cardWidth = 600.0f;
|
|
ov.idSuffix = "exportkey";
|
|
if (!m::BeginOverlayDialog(ov)) {
|
|
// Defensive scrub for a frame where the overlay renders nothing (Begin returns false). The
|
|
// actual dismiss-path wipe (Close / outside-click / Esc) is the in-body block near EndOverlayDialog.
|
|
sodium_memzero(export_result_, sizeof(export_result_));
|
|
export_address_.clear();
|
|
export_in_progress_ = false;
|
|
export_error_ = false;
|
|
return;
|
|
}
|
|
|
|
m::DialogWarningHeader(TR("key_export_private_warning"));
|
|
ImGui::Spacing();
|
|
|
|
// Address selector (a legitimate need here, unlike the import dialog).
|
|
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);
|
|
ImGui::SetNextItemWidth(-1);
|
|
const std::string comboLabel = export_address_.empty() ? std::string(TR("select_address"))
|
|
: util::truncateMiddle(export_address_, 24, 10);
|
|
if (ImGui::BeginCombo("##exportaddr", comboLabel.c_str())) {
|
|
for (const auto& addr : all_addrs) {
|
|
const bool selected = (export_address_ == addr);
|
|
if (ImGui::Selectable(util::truncateMiddle(addr, 28, 12).c_str(), selected)) {
|
|
export_address_ = addr;
|
|
sodium_memzero(export_result_, sizeof(export_result_)); // new address → old key stale
|
|
export_error_ = false;
|
|
}
|
|
}
|
|
ImGui::EndCombo();
|
|
}
|
|
|
|
// Progress / result / error. The secret key is shown in a read-only char buffer (NOT
|
|
// widgets::AddressCopyField, which would copy it into a lingering std::string) + a Copy button.
|
|
if (export_in_progress_) {
|
|
ImGui::Spacing();
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::Primary()), "%s%s",
|
|
TR("key_export_fetching"), m::LoadingDots());
|
|
} else if (export_result_[0]) {
|
|
ImGui::Spacing();
|
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("key_export_private_key"));
|
|
ImGui::InputTextMultiline("##exportresult", export_result_, sizeof(export_result_),
|
|
ImVec2(-1, 60.0f * dp), ImGuiInputTextFlags_ReadOnly);
|
|
if (m::TactileButton(TR("copy")))
|
|
ImGui::SetClipboardText(export_result_);
|
|
} else if (export_error_) {
|
|
ImGui::Spacing();
|
|
ImGui::PushTextWrapPos(0.0f);
|
|
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", TR("key_export_failed"));
|
|
ImGui::PopTextWrapPos();
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
|
|
bool doExport = false, doClose = false;
|
|
const bool canExport = !export_address_.empty() && !export_in_progress_;
|
|
m::DialogActionFooter(TR("export"), canExport, TR("close"), doExport, doClose);
|
|
if (doClose) show_export_key_ = false;
|
|
if (doExport) {
|
|
export_in_progress_ = true;
|
|
export_error_ = false;
|
|
sodium_memzero(export_result_, sizeof(export_result_));
|
|
exportPrivateKey(export_address_, [this](const std::string& key) {
|
|
// Don't populate the secret buffer if the dialog was closed before the async export
|
|
// returned (it would otherwise linger un-wiped while the dialog is shut).
|
|
export_in_progress_ = false;
|
|
if (!show_export_key_) return;
|
|
if (key.empty()) export_error_ = true;
|
|
else snprintf(export_result_, sizeof(export_result_), "%s", key.c_str());
|
|
});
|
|
}
|
|
|
|
// Scrub the secret whenever the dialog closes THIS frame — Close button OR outside-click/Esc, which
|
|
// BeginOverlayDialog clears mid-frame while still returning true (so the early-return above is not
|
|
// reached on that path). This in-body teardown mirrors the import dialog and is the actual wipe
|
|
// point for the dismiss paths; the early-return only covers a Begin-returns-false frame.
|
|
if (!show_export_key_) {
|
|
sodium_memzero(export_result_, sizeof(export_result_));
|
|
export_address_.clear();
|
|
export_in_progress_ = false;
|
|
export_error_ = false;
|
|
}
|
|
|
|
m::EndOverlayDialog();
|
|
}
|
|
|
|
void App::renderBackupDialog()
|
|
{
|
|
namespace m = ui::material;
|
|
|
|
m::OverlayDialogSpec ov;
|
|
ov.title = TR("backup_title");
|
|
ov.p_open = &show_backup_;
|
|
ov.style = m::OverlayStyle::BlurFloat;
|
|
ov.cardWidth = 560.0f;
|
|
ov.idSuffix = "backup";
|
|
if (!m::BeginOverlayDialog(ov)) return;
|
|
|
|
m::DialogWarningHeader(TR("backup_warn"));
|
|
ImGui::Spacing();
|
|
|
|
static char backup_path[512] = "dragonx-backup.txt";
|
|
static bool s_backup_confirm_overwrite = false;
|
|
ImGui::TextUnformatted(TR("backup_destination"));
|
|
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
|
|
|
|
if (!backup_status_.empty()) {
|
|
ImGui::Spacing();
|
|
ImGui::PushTextWrapPos(0.0f);
|
|
ImGui::TextColored(backup_success_ ? m::SuccessVec4() : ImVec4(0.8f, 0.3f, 0.3f, 1.0f),
|
|
"%s", backup_status_.c_str());
|
|
ImGui::PopTextWrapPos();
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
|
|
bool doSave = false, doClose = false;
|
|
m::DialogActionFooter(TR("backup_create"), true, TR("close"), doSave, doClose);
|
|
if (doClose) {
|
|
show_backup_ = false;
|
|
backup_status_.clear();
|
|
s_backup_confirm_overwrite = false;
|
|
}
|
|
if (doSave) {
|
|
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_ = TR("backup_overwrite_confirm");
|
|
} else {
|
|
s_backup_confirm_overwrite = false;
|
|
backupWallet(path, [this](bool success, const std::string& msg) {
|
|
backup_success_ = success;
|
|
backup_status_ = msg;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
m::EndOverlayDialog();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------
|
|
// Startup node files (C14). Windows release exes carry the Sapling params, asmap, the node and the miner
|
|
// inside the .exe and write them out on first run / after an upgrade (~88 MB); zip/AppImage/.app builds copy
|
|
// them from next to the executable; full-node builds then SHA-256 the params once (cached by marker). All of
|
|
// it used to run on the UI thread. It now runs on the "extract-node-files" async task.
|
|
// ---------------------------------------------------------------------------------------------------
|
|
void App::startStartupFilesProvision()
|
|
{
|
|
startup_files_ = std::make_shared<resources::StartupProvision>();
|
|
|
|
const bool embedded = resources::hasEmbeddedResources();
|
|
const bool fullNode = supportsEmbeddedDaemon(); // compile-time; Lite provisions only the miner
|
|
// Stat-only pre-check (a handful of stats + the .sapling_verified marker): a steady-state launch starts no
|
|
// thread and shows nothing.
|
|
bool needed = embedded && resources::needsParamsExtraction();
|
|
if (!needed && !embedded && fullNode) {
|
|
needed = !resources::planBundleCopy(resources::bundleSearchDirs(), resources::getDaemonDirectory(),
|
|
resources::nodeBundleFileNames(), /*replaceExisting=*/false).empty();
|
|
}
|
|
if (!needed && fullNode)
|
|
needed = rpc::Connection::saplingParamState() == rpc::Connection::SaplingParamState::NeedsHash;
|
|
if (!needed) { startup_files_->markNothingToDo(); return; }
|
|
|
|
DEBUG_LOGF("Startup: preparing node files in the background...\n");
|
|
startup_files_->begin();
|
|
auto st = startup_files_; // the job owns a reference and captures NO App state
|
|
auto job = [st, embedded, fullNode](const util::AsyncTaskManager::Token& token) {
|
|
// Every shutdown path calls async_tasks_.cancelAll() first, so the token is the only cancel source.
|
|
auto cancelled = [&token]() { return token.cancelled(); };
|
|
resources::StartupProvision::Result r;
|
|
try {
|
|
if (embedded) {
|
|
r.extractOk = resources::extractEmbeddedResources(&st->progress, cancelled);
|
|
} else if (fullNode) {
|
|
r.extractOk = resources::copyBundledFiles(resources::bundleSearchDirs(),
|
|
resources::getDaemonDirectory(),
|
|
resources::nodeBundleFileNames(),
|
|
/*replaceExisting=*/false, &st->progress, cancelled).ok;
|
|
}
|
|
// Verify even if some file failed (an antivirus-blocked miner must not skip the param check).
|
|
if (fullNode && !cancelled()) {
|
|
st->progress.setPhase(resources::ProvisionPhase::Verifying);
|
|
r.verifyOk = rpc::Connection::verifySaplingParams(); // hashes once, then writes the marker
|
|
if (!r.verifyOk && !embedded && !cancelled()) {
|
|
// A copied distribution whose daemon-dir param exists but is corrupt/truncated: the old code
|
|
// skipped existing files forever. Replace the params from the bundle and verify again.
|
|
st->paramRepairTried.store(true, std::memory_order_relaxed);
|
|
const auto rep = resources::copyBundledFiles(resources::bundleSearchDirs(),
|
|
resources::getDaemonDirectory(),
|
|
resources::saplingParamFileNames(),
|
|
/*replaceExisting=*/true, &st->progress, cancelled);
|
|
if (rep.copied > 0 && !cancelled()) {
|
|
st->progress.setPhase(resources::ProvisionPhase::Verifying);
|
|
r.verifyOk = rpc::Connection::verifySaplingParams();
|
|
}
|
|
}
|
|
}
|
|
} catch (const std::exception& e) {
|
|
DEBUG_LOGF("[ERROR] Startup file preparation threw: %s\n", e.what());
|
|
r.extractOk = false;
|
|
} catch (...) {
|
|
DEBUG_LOGF("[ERROR] Startup file preparation threw an unknown exception\n");
|
|
r.extractOk = false;
|
|
}
|
|
r.cancelled = token.cancelled();
|
|
st->finish(r); // ALWAYS: a job stuck in Running would defer every daemon start until shutdown
|
|
};
|
|
try {
|
|
async_tasks_.submit("extract-node-files", job);
|
|
} catch (const std::exception& e) {
|
|
// No thread could be created: do it in line, exactly as before this change (blocks once).
|
|
DEBUG_LOGF("[WARN] Startup: no background thread for file preparation (%s) - running it in line\n", e.what());
|
|
job(util::AsyncTaskManager::Token{});
|
|
}
|
|
}
|
|
|
|
void App::pollStartupFiles()
|
|
{
|
|
resources::StartupProvision::Result r;
|
|
if (!startup_files_ || !startup_files_->takeCompletion(&r)) return;
|
|
if (r.cancelled || shutting_down_) return;
|
|
DEBUG_LOGF("Startup: node files done (write=%d verify=%d)\n", r.extractOk ? 1 : 0, r.verifyOk ? 1 : 0);
|
|
// Deliberate improvement: the old code swallowed a write failure until the node refused to start. Only
|
|
// when the node files are unusable — a miner blocked by antivirus (common) must not nag every launch.
|
|
if (supportsEmbeddedDaemon() && !r.extractOk && !r.verifyOk)
|
|
ui::Notifications::instance().warning(TR("sb_daemon_extract_failed"), 20.0f);
|
|
// The miner version probe waits for the final miner on disk (deferred from init()).
|
|
if (supportsPoolMining()) daemon::XmrigManager::startVersionDetection();
|
|
// A start was requested while the files were being written (wizard pre-start / Done, first tryConnect, the
|
|
// connect MainCb): replay it — but never into a bootstrap download (which stops the node on purpose), an
|
|
// orchestrated restart, or an already-connected session. Every UI-thread requester also calls tryConnect().
|
|
if (daemon_start_after_files_) {
|
|
daemon_start_after_files_ = false;
|
|
const bool bootstrapping = bootstrap_downloading_ || wizard_phase_ == WizardPhase::BootstrapInProgress;
|
|
if (!bootstrapping && !daemon_restarting_ && !state_.connected) {
|
|
if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) startEmbeddedDaemon();
|
|
if (!connection_in_progress_) tryConnect();
|
|
} else if (getDaemonStatus() == TR("sb_waiting_daemon")) {
|
|
setDaemonStatus(std::string()); // the deferral's status, unless another flow replaced it since
|
|
}
|
|
}
|
|
}
|
|
|
|
void App::renderStartupFilesChip()
|
|
{
|
|
if (!startup_files_ || !startup_files_->isRunning()) return;
|
|
ui::material::RequestProgressFrame(); // keeps the percentage moving (~10 fps) while the job runs
|
|
const auto& S = ui::schema::UI();
|
|
auto val = [&S](const char* key, float fallback) {
|
|
return S.drawElement("components.startup-files-chip", key).sizeOr(fallback);
|
|
};
|
|
if (!resources::provisionChipVisible(true, startup_files_->elapsedSeconds(), val("show-delay-seconds", 0.4f)))
|
|
return; // a job that ends within the grace period never flashes a chip
|
|
|
|
namespace m = ui::material;
|
|
const auto& pr = startup_files_->progress;
|
|
const bool verifying = pr.getPhase() == resources::ProvisionPhase::Verifying;
|
|
const int pct = resources::provisionPercent(pr.bytesDone.load(std::memory_order_relaxed),
|
|
pr.bytesTotal.load(std::memory_order_relaxed));
|
|
char buf[160];
|
|
if (verifying) snprintf(buf, sizeof(buf), "%s", TR("startup_files_verifying"));
|
|
else snprintf(buf, sizeof(buf), TR("startup_files_preparing"), pct);
|
|
|
|
// Hand-drawn geometry: ui.toml values are logical px -> multiply by dpiScale(); font metrics already are.
|
|
const float dp = ui::Layout::dpiScale();
|
|
ImFont* f = m::Type().caption();
|
|
const float padX = val("padding-x", 14.0f) * dp, barH = val("bar-height", 3.0f) * dp;
|
|
const float barBottom = val("bar-bottom", 10.0f) * dp;
|
|
const float textW = f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0.0f, buf).x;
|
|
ImGuiViewport* vp = ImGui::GetMainViewport();
|
|
const float w = std::min(std::max(val("width", 340.0f) * dp, textW + 2.0f * padX), vp->Size.x - 2.0f * padX);
|
|
const float h = val("height", 46.0f) * dp;
|
|
const float margin = val("bottom-margin", 38.0f) * dp, rounding = val("rounding", 10.0f) * dp;
|
|
const ImVec2 mn(vp->Pos.x + (vp->Size.x - w) * 0.5f, vp->Pos.y + vp->Size.y - h - margin);
|
|
const ImVec2 mx(mn.x + w, mn.y + h);
|
|
ImDrawList* dl = ImGui::GetForegroundDrawList();
|
|
dl->AddRectFilled(mn, mx, m::SurfaceAt(m::Elevation::Dp6), rounding); // opaque (Surface() is translucent)
|
|
dl->AddRect(mn, mx, m::WithAlpha(m::OnSurface(), 40), rounding, 0, 1.0f * dp);
|
|
const float textY = mn.y + (h - barBottom - barH - f->LegacySize) * 0.5f;
|
|
dl->AddText(f, f->LegacySize, ImVec2(mn.x + padX, textY), m::OnSurface(), buf);
|
|
|
|
const float bx0 = mn.x + padX, bx1 = mx.x - padX, by1 = mx.y - barBottom, by0 = by1 - barH;
|
|
dl->AddRectFilled(ImVec2(bx0, by0), ImVec2(bx1, by1), m::WithAlpha(m::OnSurface(), 30), barH * 0.5f);
|
|
float f0 = 0.0f, f1 = pct / 100.0f;
|
|
if (verifying) { // indeterminate: a sliding segment
|
|
const float t = std::fmod(static_cast<float>(ImGui::GetTime()) * 0.8f, 1.0f);
|
|
f0 = std::max(0.0f, t - 0.15f);
|
|
f1 = std::min(1.0f, t + 0.15f);
|
|
}
|
|
if (f1 > f0)
|
|
dl->AddRectFilled(ImVec2(bx0 + (bx1 - bx0) * f0, by0), ImVec2(bx0 + (bx1 - bx0) * f1, by1),
|
|
m::Primary(), barH * 0.5f);
|
|
}
|
|
|
|
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(/*scanVersion=*/false); // only exists/size are used here
|
|
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()
|
|
{
|
|
ui::material::OverlayDialogSpec ov;
|
|
ov.title = TR("daemon_update_prompt_title"); ov.p_open = &show_daemon_update_prompt_;
|
|
ov.style = ui::material::OverlayStyle::BlurFloat;
|
|
ov.cardWidth = 520.0f; ov.idSuffix = "daemonupdprompt";
|
|
if (!ui::material::BeginOverlayDialog(ov))
|
|
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();
|
|
if (ui::material::TactileButton(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::TactileButton(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;
|
|
}
|
|
});
|
|
}
|
|
|
|
ui::material::OverlayDialogSpec ov;
|
|
ov.title = TR("seed_backup_title");
|
|
ov.p_open = &show_seed_backup_;
|
|
ov.style = ui::material::OverlayStyle::BlurFloat;
|
|
ov.cardWidth = 560.0f;
|
|
ov.idSuffix = "seedbackup";
|
|
if (!ui::material::BeginOverlayDialog(ov)) {
|
|
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()) {
|
|
ui::material::DialogWarningHeader(TR("seed_backup_warning"));
|
|
ImGui::Spacing();
|
|
ImGui::TextWrapped("%s", TR("seed_backup_intro"));
|
|
ImGui::Spacing();
|
|
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_backup_phrase_));
|
|
ImGui::Spacing();
|
|
if (!seed_backup_status_.empty()) {
|
|
ImGui::PushTextWrapPos(0.0f);
|
|
ImGui::TextColored(ui::material::SuccessVec4(), "%s", seed_backup_status_.c_str());
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::Spacing();
|
|
}
|
|
// Footer: Copy | Save | Close — centered as a group (no divider).
|
|
const float cW = 120 * dp, sW = 150 * dp;
|
|
ui::material::PlaceOverlayDialogActions(cW + sW + cW + ImGui::GetStyle().ItemSpacing.x * 2.0f);
|
|
if (ui::material::TactileButton(TR("seed_backup_copy"), ImVec2(cW, 0))) {
|
|
copySecretToClipboard(seed_backup_phrase_);
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(TR("seed_backup_save"), ImVec2(sW, 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::TactileButton(TR("seed_backup_close"), ImVec2(cW, 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();
|
|
// Centered Close (no divider).
|
|
const float cbW = 120 * dp;
|
|
ui::material::PlaceOverlayDialogActions(cbW);
|
|
if (ui::material::TactileButton(TR("seed_backup_close"), ImVec2(cbW, 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;
|
|
// The op runs off the UI thread; keep frames coming for its progress while it does.
|
|
if (busy || seed_migration_step_ == SeedMigrationStep::Working) ui::material::RequestProgressFrame();
|
|
|
|
ui::material::OverlayDialogSpec migOv;
|
|
migOv.title = TR("mig_title"); migOv.p_open = &show_seed_migration_;
|
|
migOv.style = ui::material::OverlayStyle::BlurFloat;
|
|
migOv.cardWidth = 580.0f; migOv.idSuffix = "migrate";
|
|
if (!ui::material::BeginOverlayDialog(migOv)) {
|
|
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();
|
|
if (ui::material::TactileButton(TR("close"), ImVec2(120 * dp, 0))) close();
|
|
break;
|
|
}
|
|
if (state_.isLocked()) {
|
|
ImGui::TextWrapped("%s", TR("mig_unlock_to_migrate"));
|
|
ImGui::Spacing();
|
|
if (ui::material::TactileButton(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();
|
|
{
|
|
// Size to the label — the fixed 200px clipped the FR/PT/ES/DE/RU translations.
|
|
ImFont* bkFont = ui::material::Type().button();
|
|
const float bkW = std::max(200.0f * dp,
|
|
bkFont->CalcTextSizeA(bkFont->LegacySize, FLT_MAX, 0, TR("mig_backup_instead")).x
|
|
+ ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp);
|
|
if (ui::material::TactileButton(TR("mig_backup_instead"), ImVec2(bkW, 0))) {
|
|
close();
|
|
showSeedBackupDialog();
|
|
}
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(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();
|
|
if (ui::material::TactileButton(TR("close"), ImVec2(120 * dp, 0))) close();
|
|
break;
|
|
case SeedMigrationPrecheck::CheckFailed:
|
|
ImGui::TextWrapped("%s", TR("mig_check_failed"));
|
|
ImGui::Spacing();
|
|
if (ui::material::TactileButton(TR("mig_recheck"), ImVec2(120 * dp, 0)))
|
|
seed_migration_precheck_started_ = false; // re-probe next frame
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(TR("close"), ImVec2(120 * dp, 0))) close();
|
|
break;
|
|
case SeedMigrationPrecheck::Legacy:
|
|
default:
|
|
ImGui::TextWrapped("%s", TR("mig_intro"));
|
|
ImGui::Spacing();
|
|
if (ui::material::TactileButton(TR("mig_create"), ImVec2(180 * dp, 0)))
|
|
beginCreateSeedWallet();
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(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: {
|
|
ui::material::DialogWarningHeader(TR("mig_seed_warning"));
|
|
ImGui::Spacing();
|
|
// Box the 24-word grid in a glass panel so the wallet's most critical secret reads as a
|
|
// distinct artifact, not body copy. Purely visual — the words/derivation/logic are untouched.
|
|
{
|
|
ui::material::GlassSectionScope seedPanel;
|
|
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::TactileButton(TR("mig_copy_seed"), ImVec2(120 * dp, 0)))
|
|
copySecretToClipboard(seed_migration_seed_);
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(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();
|
|
if (!seed_migration_backed_up_) ImGui::BeginDisabled();
|
|
if (ui::material::TactileButton(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::TactileButton(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();
|
|
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0))) close();
|
|
break;
|
|
}
|
|
if (seed_migration_balance_ <= DRAGONX_DEFAULT_FEE) {
|
|
const bool sweepSubmitted = settings_ && settings_->getSeedMigrationSweepSubmitted();
|
|
if (!dragonx::sweepGateMayAdoptEmptyWallet(sweepSubmitted)) {
|
|
// A sweep was already broadcast but its txid was lost (its opid didn't survive a
|
|
// daemon/app restart). A ~0 balance is AMBIGUOUS — the sweep may still be unconfirmed
|
|
// in the mempool and could revert on a drop/reorg — so we must NOT adopt (swap
|
|
// wallet.dat) here. Try to locate the sweep tx to resume the confirmation gate (which
|
|
// requires >=1 conf); until then, wait/refresh. If the sweep was dropped the balance
|
|
// simply returns and the normal Sweep UI reappears — the user is never trapped.
|
|
ImGui::TextWrapped("%s", TR("mig_sweep_inflight"));
|
|
ImGui::Spacing();
|
|
ImGui::TextColored(kMedium, "%s%s", TR("mig_checking_sweep"), ui::material::LoadingDots());
|
|
ImGui::Spacing();
|
|
if (ui::material::TactileButton(TR("mig_check_sweep"), ImVec2(190 * dp, 0)))
|
|
locateSweepTxid();
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0))) {
|
|
refreshSeedMigrationBalance();
|
|
locateSweepTxid();
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
|
|
close();
|
|
break;
|
|
}
|
|
// Nothing to sweep and no sweep was ever submitted (a genuinely empty wallet) — 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();
|
|
if (!seed_migration_nofunds_confirmed_) ImGui::BeginDisabled();
|
|
if (ui::material::TactileButton(TR("mig_adopt_now"), ImVec2(190 * dp, 0)))
|
|
beginAdoptSeedWallet();
|
|
if (!seed_migration_nofunds_confirmed_) ImGui::EndDisabled();
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
|
refreshSeedMigrationBalance();
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(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();
|
|
{
|
|
const bool canSweep = !state_.isLocked() && seed_migration_balance_ > 0.0;
|
|
if (!canSweep) ImGui::BeginDisabled();
|
|
if (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0)))
|
|
beginSweepToSeedWallet();
|
|
if (!canSweep) ImGui::EndDisabled();
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
|
refreshSeedMigrationBalance();
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(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()) {
|
|
// A 64-hex txid overflows the fixed 580px card; render it in a wrapping, copyable field
|
|
// (same widget the import-key sweep result uses) instead of a raw one-line label.
|
|
ImGui::TextColored(kMedium, "%s", TR("mig_txid"));
|
|
ui::widgets::AddressCopyField("##migtxid", seed_migration_sweep_txid_);
|
|
}
|
|
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();
|
|
if (confirmed && legacyEmpty) {
|
|
if (ui::material::TactileButton(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::TactileButton(TR("mig_sweep_remaining"), ImVec2(180 * dp, 0)))
|
|
beginSweepToSeedWallet();
|
|
} else {
|
|
ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine"));
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
|
pollSweepStatus();
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(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();
|
|
if (ui::material::TactileButton(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();
|
|
if (ui::material::TactileButton(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;
|
|
|
|
ui::material::OverlayDialogSpec ov;
|
|
ov.title = TR("av_title"); ov.p_open = &pending_antivirus_dialog_;
|
|
ov.style = ui::material::OverlayStyle::BlurFloat;
|
|
ov.cardWidth = 560.0f; ov.idSuffix = "antivirus";
|
|
if (!ui::material::BeginOverlayDialog(ov)) {
|
|
return;
|
|
}
|
|
const float dp = ui::Layout::dpiScale();
|
|
|
|
ImGui::TextWrapped("%s", TR("av_intro"));
|
|
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
ui::material::Type().text(ui::material::TypeStyle::Subtitle2, TR("av_step1"));
|
|
ImGui::BulletText("%s", TR("av_step1_b1"));
|
|
ImGui::BulletText("%s", TR("av_step1_b2"));
|
|
ImGui::BulletText("%s", TR("av_step1_b3"));
|
|
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
ui::material::Type().text(ui::material::TypeStyle::Subtitle2, TR("av_step2"));
|
|
ImGui::BulletText("%s", TR("av_step2_b1"));
|
|
ImGui::BulletText("%s", TR("av_step2_b2"));
|
|
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
ui::material::Type().text(ui::material::TypeStyle::Subtitle2, TR("av_step3"));
|
|
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
|
|
|
float btnW = 160.0f * dp;
|
|
if (ui::material::TactileButton(TR("av_open_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::TactileButton(TR("close"), ImVec2(100.0f * dp, 0))) {
|
|
pending_antivirus_dialog_ = false;
|
|
}
|
|
|
|
ui::material::EndOverlayDialog();
|
|
#endif
|
|
}
|
|
|
|
// Auto-shown when the node auto-recovered (salvaged) wallet.dat: the real wallet is safe in a
|
|
// wallet.<timestamp>.bak, but a possibly-incomplete salvaged copy is now loaded — warn loudly and point
|
|
// the user at the datadir so they can restore the original instead of mistaking it for fund loss.
|
|
void App::renderWalletRecoveredDialog()
|
|
{
|
|
if (!show_wallet_recovered_dialog_) return;
|
|
|
|
ui::material::OverlayDialogSpec ov;
|
|
ov.title = TR("wallet_recovered_title");
|
|
// Dismiss (backdrop / [X]) is disabled during Working — files are mid-swap. A null p_open is safe:
|
|
// BeginOverlayDialog guards both the close button and backdrop-close on it.
|
|
ov.p_open = (recovery_phase_ == RecoveryPhase::Working) ? nullptr : &show_wallet_recovered_dialog_;
|
|
ov.style = ui::material::OverlayStyle::BlurFloat;
|
|
ov.cardWidth = 620.0f; // wide enough for the two side-by-side choice cards
|
|
ov.idSuffix = "walletrecovered";
|
|
if (!ui::material::BeginOverlayDialog(ov)) return;
|
|
const float dp = ui::Layout::dpiScale();
|
|
// Size each button to its label (with a modest floor) instead of a magic fixed width, so
|
|
// translations of these keys — added additively to res/lang later — can't silently clip.
|
|
ImFont* rbf = ui::material::Type().button();
|
|
auto fitBtnW = [&](const char* label) {
|
|
return std::max(96.0f * dp,
|
|
rbf->CalcTextSizeA(rbf->LegacySize, FLT_MAX, 0, label).x
|
|
+ ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp);
|
|
};
|
|
// A full-width action row: the button, then a dim wrapped one-line explanation of what it does.
|
|
auto actionRow = [&](const char* label, const char* sub) -> bool {
|
|
const bool clicked = ui::material::TactileButton(label, ImVec2(fitBtnW(label), 0));
|
|
if (sub && sub[0]) {
|
|
ImGui::PushFont(ui::material::Type().caption());
|
|
ImGui::PushTextWrapPos(0.0f);
|
|
ImGui::TextDisabled("%s", sub);
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::PopFont();
|
|
}
|
|
return clicked;
|
|
};
|
|
// A disclosure that recedes: no filled bar, dim label — so the primary action stays dominant.
|
|
auto quietHeader = [&](const char* label) -> bool {
|
|
ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0, 0, 0, 0));
|
|
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ui::material::WithAlpha(ui::material::OnSurface(), 20));
|
|
ImGui::PushStyleColor(ImGuiCol_HeaderActive, ui::material::WithAlpha(ui::material::OnSurface(), 32));
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
|
|
const bool open = ImGui::CollapsingHeader(label);
|
|
ImGui::PopStyleColor(4);
|
|
return open;
|
|
};
|
|
// Opt-in daemon log — the same lines that used to be dumped on screen, now behind a quiet disclosure.
|
|
auto techDetails = [&]() {
|
|
if (!daemon_controller_) return;
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
if (quietHeader(TR("wallet_recovery_details_label"))) {
|
|
ImGui::PushFont(ui::material::Type().caption());
|
|
ImGui::PushTextWrapPos(0.0f);
|
|
for (const auto& ln : daemon_controller_->recentLines(8))
|
|
ImGui::TextDisabled("%s", ln.c_str());
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::PopFont();
|
|
}
|
|
};
|
|
// Funds-safety hero: a shield icon + the reassurance, made the visual focal point of the screen.
|
|
auto safetyHero = [&]() {
|
|
ImFont* icoF = ui::material::Type().iconLarge();
|
|
ImFont* txtF = ui::material::Type().subtitle1();
|
|
const float rowTop = ImGui::GetCursorPosY();
|
|
ImGui::PushFont(icoF);
|
|
ImGui::TextColored(ui::material::SuccessVec4(), ICON_MD_HEALTH_AND_SAFETY);
|
|
ImGui::PopFont();
|
|
ImGui::SameLine();
|
|
const float iconH = icoF->LegacySize;
|
|
const float textH = txtF ? txtF->LegacySize : ImGui::GetFontSize();
|
|
if (iconH > textH) ImGui::SetCursorPosY(rowTop + (iconH - textH) * 0.5f);
|
|
ImGui::PushFont(txtF);
|
|
ImGui::TextColored(ui::material::SuccessVec4(), "%s", TR("wallet_recovered_safety"));
|
|
ImGui::PopFont();
|
|
};
|
|
// Accent-tinted primary action so the recommended button clearly dominates the quiet disclosures
|
|
// (a translucent Primary tint over the glass button — keeps the label readable on any theme).
|
|
auto primaryAction = [&](const char* label, const char* sub) -> bool {
|
|
ImGui::PushStyleColor(ImGuiCol_Button, ui::material::WithAlpha(ui::material::Primary(), 60));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::material::WithAlpha(ui::material::Primary(), 90));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ui::material::WithAlpha(ui::material::Primary(), 115));
|
|
const bool clicked = actionRow(label, sub);
|
|
ImGui::PopStyleColor(3);
|
|
return clicked;
|
|
};
|
|
// A choice card: icon + title (+ optional "recommended" chip) + wrapped blurb + a full-width button
|
|
// pinned to the card bottom. The recommended card gets a gold-tinted fill + border so the choice
|
|
// reads at a glance. cardH is precomputed by the caller so side-by-side cards stay equal height.
|
|
auto renderCard = [&](const char* id, const char* glyph, const char* title, const char* desc,
|
|
const char* btn, bool recommended, float cardW, float cardH) -> bool {
|
|
const float pad = ui::Layout::spacingMd();
|
|
ImGui::PushStyleColor(ImGuiCol_ChildBg,
|
|
recommended ? ui::material::WithAlpha(ui::material::Primary(), 22)
|
|
: ui::material::WithAlpha(ui::material::OnSurface(), 8));
|
|
ImGui::PushStyleColor(ImGuiCol_Border,
|
|
recommended ? ui::material::WithAlpha(ui::material::Primary(), 140)
|
|
: ui::material::WithAlpha(ui::material::OnSurface(), 28));
|
|
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 6.0f * dp);
|
|
ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, 1.0f);
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(pad, pad));
|
|
ImGui::BeginChild(id, ImVec2(cardW, cardH), true,
|
|
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
|
// Zero implicit item spacing so the only vertical gaps are the explicit Dummy()s below — that
|
|
// keeps the caller's measured cardH exact, so the bottom-pinned button never clips.
|
|
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.0f, 0.0f));
|
|
|
|
ImGui::PushFont(ui::material::Type().iconMed());
|
|
ImGui::TextColored(recommended ? ui::material::PrimaryVec4()
|
|
: ImGui::ColorConvertU32ToFloat4(ui::material::OnSurfaceMedium()),
|
|
"%s", glyph);
|
|
ImGui::PopFont();
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs()));
|
|
|
|
ImGui::PushFont(ui::material::Type().subtitle2());
|
|
ImGui::TextUnformatted(title);
|
|
ImGui::PopFont();
|
|
if (recommended) {
|
|
ImGui::PushFont(ui::material::Type().caption());
|
|
ImGui::TextColored(ui::material::PrimaryVec4(), "%s", TR("wallet_recovery_recommended"));
|
|
ImGui::PopFont();
|
|
}
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs()));
|
|
|
|
ImGui::PushFont(ui::material::Type().caption());
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
|
|
ImGui::PushTextWrapPos(0.0f);
|
|
ImGui::TextWrapped("%s", desc);
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopFont();
|
|
|
|
// Pin the button to the card bottom so both cards' buttons line up.
|
|
const float btnH = ImGui::GetFrameHeight() + 6.0f * dp;
|
|
const float remaining = ImGui::GetContentRegionAvail().y - btnH;
|
|
if (remaining > 0.0f) ImGui::Dummy(ImVec2(0, remaining));
|
|
bool clicked;
|
|
if (recommended) {
|
|
ImGui::PushStyleColor(ImGuiCol_Button, ui::material::WithAlpha(ui::material::Primary(), 65));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::material::WithAlpha(ui::material::Primary(), 100));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ui::material::WithAlpha(ui::material::Primary(), 125));
|
|
clicked = ui::material::TactileButton(btn, ImVec2(-FLT_MIN, btnH));
|
|
ImGui::PopStyleColor(3);
|
|
} else {
|
|
clicked = ui::material::TactileButton(btn, ImVec2(-FLT_MIN, btnH));
|
|
}
|
|
ImGui::PopStyleVar(); // ItemSpacing
|
|
ImGui::EndChild();
|
|
ImGui::PopStyleVar(3);
|
|
ImGui::PopStyleColor(2);
|
|
return clicked;
|
|
};
|
|
// A quiet clickable text link for the footer (Show me the files / Not now).
|
|
auto linkText = [&](const char* label) -> bool {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
|
|
ImGui::TextUnformatted(label);
|
|
ImGui::PopStyleColor();
|
|
const bool clicked = ImGui::IsItemClicked();
|
|
if (ImGui::IsItemHovered()) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
const ImVec2 mn = ImGui::GetItemRectMin(), mx = ImGui::GetItemRectMax();
|
|
ImGui::GetWindowDrawList()->AddLine(ImVec2(mn.x, mx.y), ImVec2(mx.x, mx.y),
|
|
ui::material::OnSurface());
|
|
}
|
|
return clicked;
|
|
};
|
|
|
|
switch (recovery_phase_) {
|
|
case RecoveryPhase::Offer: {
|
|
safetyHero();
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
ImGui::TextWrapped("%s", TR("wallet_recovered_warn"));
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
|
|
|
// The two real actions as side-by-side CHOICE CARDS — the recommended one gold-tinted, so the
|
|
// decision reads at a glance instead of hiding in a menu. Rebuild is the more complete fix; when
|
|
// its helper is missing, Restore becomes the single recommended card (never a dead end).
|
|
const bool canRebuild = walletRebuildAvailable();
|
|
const float cardGap = ui::Layout::spacingMd();
|
|
const float pad = cardGap;
|
|
const float contentW = ImGui::GetContentRegionAvail().x;
|
|
const float cardW = canRebuild ? (contentW - cardGap) * 0.5f
|
|
: std::min(contentW, 320.0f * dp);
|
|
// Fix a shared card height off the taller card body so the two cards line up. Measure title AND
|
|
// desc wraps (titles/descs can be multi-line, esp. after translation), with a little width slack.
|
|
ImFont* descF = ui::material::Type().caption();
|
|
ImFont* titleF = ui::material::Type().subtitle2();
|
|
const float innerW = std::max(1.0f, cardW - 2.0f * pad - 6.0f * dp);
|
|
auto measureH = [&](ImFont* f, const char* s) {
|
|
return f->CalcTextSizeA(f->LegacySize, FLT_MAX, innerW, s).y;
|
|
};
|
|
const float titleH = std::max(measureH(titleF, TR("wallet_recovery_rebuild_card")),
|
|
measureH(titleF, TR("wallet_recovery_restore_card")));
|
|
const float descH = std::max(measureH(descF, TR("wallet_recovery_rebuild_card_desc")),
|
|
measureH(descF, TR("wallet_recovery_restore_card_desc")));
|
|
const float cardH = 2.0f * pad
|
|
+ ui::material::Type().iconMed()->LegacySize + ui::Layout::spacingXs() // icon + gap
|
|
+ titleH + descF->LegacySize // title + RECOMMENDED chip
|
|
+ ui::Layout::spacingXs() // gap before desc
|
|
+ descH + ui::Layout::spacingSm() // desc + gap before button
|
|
+ ImGui::GetFrameHeight() + 6.0f * dp // button
|
|
+ ui::Layout::spacingXs(); // small buffer
|
|
|
|
if (canRebuild) {
|
|
const bool r = renderCard("##rcRepair", ICON_MD_AUTO_FIX_HIGH, TR("wallet_recovery_rebuild_card"),
|
|
TR("wallet_recovery_rebuild_card_desc"), TR("wallet_recovery_repair_go"),
|
|
true, cardW, cardH);
|
|
ImGui::SameLine(0, cardGap);
|
|
const bool s = renderCard("##rcRestore", ICON_MD_SETTINGS_BACKUP_RESTORE, TR("wallet_recovery_restore_card"),
|
|
TR("wallet_recovery_restore_card_desc"), TR("wallet_recovery_restore_go"),
|
|
false, cardW, cardH);
|
|
if (r) rebuildWalletDatabase();
|
|
if (s) restoreOriginalWallet();
|
|
} else {
|
|
const float indent = (contentW - cardW) * 0.5f;
|
|
if (indent > 0.0f) ImGui::Indent(indent);
|
|
const bool s = renderCard("##rcRestoreOnly", ICON_MD_SETTINGS_BACKUP_RESTORE, TR("wallet_recovery_restore_card"),
|
|
TR("wallet_recovery_restore_card_desc"), TR("wallet_recovery_restore_go"),
|
|
true, cardW, cardH);
|
|
if (indent > 0.0f) ImGui::Unindent(indent);
|
|
if (s) restoreOriginalWallet();
|
|
}
|
|
|
|
// Quiet footer: inspect-files link + a clearly-labelled, reversible dismiss (with a tooltip that
|
|
// spells out the consequence — plain "Not now" was ambiguous).
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
|
if (linkText(TR("wallet_recovered_open_folder")))
|
|
util::Platform::openFolder(util::Platform::getDragonXDataDir());
|
|
ImGui::SameLine(0, ui::Layout::spacingSm());
|
|
ImGui::TextDisabled("\xC2\xB7"); // middle dot separator
|
|
ImGui::SameLine(0, ui::Layout::spacingSm());
|
|
if (linkText(TR("wallet_recovery_decide_later"))) {
|
|
show_wallet_recovered_dialog_ = false; // non-destructive; reopen from the status bar
|
|
recovery_phase_ = RecoveryPhase::Offer;
|
|
}
|
|
if (ImGui::IsItemHovered()) {
|
|
ImGui::BeginTooltip();
|
|
ImGui::PushTextWrapPos(ImGui::GetFontSize() * 22.0f);
|
|
ImGui::TextUnformatted(TR("wallet_recovery_decide_later_tip"));
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::EndTooltip();
|
|
}
|
|
// Plain-language "what happens to my files" — replaces the unhelpful raw daemon log on this screen
|
|
// (the log is still available in the Console tab for troubleshooting).
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
if (quietHeader(TR("wallet_recovery_files_label"))) {
|
|
ImGui::PushFont(ui::material::Type().caption());
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
|
|
ImGui::PushTextWrapPos(0.0f);
|
|
ImGui::TextWrapped("%s", TR("wallet_recovery_files_detail"));
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopFont();
|
|
}
|
|
break;
|
|
}
|
|
case RecoveryPhase::Working: {
|
|
ImGui::TextWrapped("%s", recovery_last_action_rebuild_ ? TR("wallet_rebuild_started")
|
|
: TR("wallet_restore_started"));
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
|
ImGui::Text("%s%s", TR("wallet_recovery_working_label"), ui::material::LoadingDots());
|
|
break;
|
|
}
|
|
case RecoveryPhase::Done: {
|
|
safetyHero();
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
ImGui::TextWrapped("%s", recovery_last_action_rebuild_ ? TR("wallet_recovery_success_body")
|
|
: TR("wallet_recovery_success_restore"));
|
|
// A sev-1 warning (e.g. restored but the node didn't relaunch) carries a specific message.
|
|
if (recovery_outcome_sev_ == 1 && !recovery_outcome_msg_.empty()) {
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs()));
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning());
|
|
ImGui::TextWrapped("%s", recovery_outcome_msg_.c_str());
|
|
ImGui::PopStyleColor();
|
|
}
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
|
if (ui::material::TactileButton(TR("wallet_recovery_done"), ImVec2(fitBtnW(TR("wallet_recovery_done")), 0))) {
|
|
show_wallet_recovered_dialog_ = false;
|
|
recovery_phase_ = RecoveryPhase::Offer;
|
|
// Clean success — clear the session flag so a later unrelated disconnect doesn't re-raise the
|
|
// "repair available" chip/dialog for a wallet that's already fixed. (Warnings keep it set.)
|
|
if (recovery_outcome_sev_ == 0) wallet_auto_recovered_ = false;
|
|
}
|
|
techDetails();
|
|
break;
|
|
}
|
|
case RecoveryPhase::Failed: {
|
|
ImGui::PushFont(ui::material::Type().subtitle1());
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
|
ImGui::TextWrapped("%s", TR("wallet_recovery_failure_title"));
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopFont();
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
ImGui::TextWrapped("%s", TR("wallet_recovery_failure_body"));
|
|
if (!recovery_outcome_msg_.empty()) {
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs()));
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
|
ImGui::TextWrapped("%s", recovery_outcome_msg_.c_str());
|
|
ImGui::PopStyleColor();
|
|
}
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
|
// Offer the UNtried option (Restore needs no helper; Rebuild only if its helper exists).
|
|
if (recovery_last_action_rebuild_) {
|
|
if (actionRow(TR("wallet_recovery_try_other"), TR("wallet_recovered_restore_sub")))
|
|
restoreOriginalWallet();
|
|
} else if (walletRebuildAvailable()) {
|
|
if (actionRow(TR("wallet_recovery_try_other"), TR("wallet_recovered_rebuild_sub")))
|
|
rebuildWalletDatabase();
|
|
}
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
if (actionRow(TR("wallet_recovered_open_folder"), TR("wallet_recovered_open_folder_sub")))
|
|
util::Platform::openFolder(util::Platform::getDragonXDataDir());
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
if (ui::material::TactileButton(TR("close"), ImVec2(fitBtnW(TR("close")), 0))) {
|
|
show_wallet_recovered_dialog_ = false;
|
|
recovery_phase_ = RecoveryPhase::Offer;
|
|
}
|
|
techDetails();
|
|
break;
|
|
}
|
|
}
|
|
|
|
ui::material::EndOverlayDialog();
|
|
}
|
|
|
|
// Auto-shown when the active wallet loaded EMPTY but a sibling wallet file in the datadir still holds keys
|
|
// (see maybeWarnEmptyWalletWithFundedSiblings). Funds are not lost — they're in another file, most likely a
|
|
// wallet.<ts>.bak left by an earlier BDB salvage. This routes the user to the wallet manager to switch, and
|
|
// remembers a per-file dismissal so it never nags again for this wallet.
|
|
void App::renderEmptyWalletWarningDialog()
|
|
{
|
|
if (!show_empty_wallet_warning_) return;
|
|
|
|
const bool salvage = empty_wallet_has_salvage_bak_; // salvage .bak → offer Restore; else → switch wallet
|
|
|
|
ui::material::OverlayDialogSpec ov;
|
|
ov.title = TR(salvage ? "empty_wallet_salvage_title" : "empty_wallet_warning_title");
|
|
ov.p_open = &show_empty_wallet_warning_;
|
|
ov.style = ui::material::OverlayStyle::BlurFloat;
|
|
ov.cardWidth = 560.0f;
|
|
ov.idSuffix = "emptywalletwarn";
|
|
if (!ui::material::BeginOverlayDialog(ov)) return;
|
|
const float dp = ui::Layout::dpiScale();
|
|
|
|
// Header: wallet icon in a warning tint + a calm "your coins are likely in another file" framing.
|
|
{
|
|
ImFont* icoF = ui::material::Type().iconLarge();
|
|
const float rowTop = ImGui::GetCursorPosY();
|
|
ImGui::PushFont(icoF);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning());
|
|
ImGui::TextUnformatted(ICON_MD_ACCOUNT_BALANCE_WALLET);
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopFont();
|
|
ImGui::SameLine();
|
|
ImFont* txtF = ui::material::Type().subtitle1();
|
|
const float iconH = icoF->LegacySize;
|
|
const float textH = txtF ? txtF->LegacySize : ImGui::GetFontSize();
|
|
if (iconH > textH) ImGui::SetCursorPosY(rowTop + (iconH - textH) * 0.5f);
|
|
ImGui::PushFont(txtF);
|
|
ImGui::TextWrapped("%s", TR(salvage ? "empty_wallet_salvage_headline" : "empty_wallet_warning_headline"));
|
|
ImGui::PopFont();
|
|
}
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
|
|
ImGui::PushTextWrapPos(0.0f);
|
|
ImGui::TextWrapped("%s", TR(salvage ? "empty_wallet_salvage_body" : "empty_wallet_warning_body"));
|
|
ImGui::PopTextWrapPos();
|
|
|
|
// For the "wrong wallet" case, name the other wallet file(s) that hold keys, with a compact key count —
|
|
// concrete evidence the coins are recoverable from them. The count is built with std::to_string so no
|
|
// printf format lives in a translatable string (translations are additive and could otherwise drop a %d).
|
|
// (The salvage case has no sibling list — restoreOriginalWallet() finds the backup itself.)
|
|
if (!empty_wallet_funded_siblings_.empty()) {
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
for (const auto& s : empty_wallet_funded_siblings_) {
|
|
ImGui::Bullet();
|
|
ImGui::SameLine();
|
|
ImGui::TextUnformatted(s.fileName.c_str());
|
|
ImGui::PushFont(ui::material::Type().caption());
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
|
|
const std::string keys = " " + std::to_string(s.transparentKeys + s.shieldedKeys)
|
|
+ " " + TR("empty_wallet_keys_suffix");
|
|
ImGui::SameLine();
|
|
ImGui::TextUnformatted(keys.c_str());
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopFont();
|
|
}
|
|
}
|
|
|
|
// Primary action: route the user to the wallet manager to switch files (accent-tinted so it dominates).
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
|
ImFont* rbf = ui::material::Type().button();
|
|
auto fitBtnW = [&](const char* label) {
|
|
return std::max(120.0f * dp,
|
|
rbf->CalcTextSizeA(rbf->LegacySize, FLT_MAX, 0, label).x
|
|
+ ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp);
|
|
};
|
|
const char* primaryLabel = salvage ? TR("empty_wallet_restore") : TR("empty_wallet_open_manager");
|
|
ImGui::PushStyleColor(ImGuiCol_Button, ui::material::WithAlpha(ui::material::Primary(), 65));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::material::WithAlpha(ui::material::Primary(), 100));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ui::material::WithAlpha(ui::material::Primary(), 125));
|
|
if (ui::material::TactileButton(primaryLabel, ImVec2(fitBtnW(primaryLabel), 0))) {
|
|
show_empty_wallet_warning_ = false;
|
|
if (salvage)
|
|
restoreOriginalWallet(); // self-contained: swaps the .bak back + drives the recovery dialog's progress
|
|
else
|
|
ui::WalletsDialog::show(this);
|
|
}
|
|
ImGui::PopStyleColor(3);
|
|
|
|
// Quiet footer: open the data folder, or dismiss permanently for THIS wallet file.
|
|
auto linkText = [&](const char* label) -> bool {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
|
|
ImGui::TextUnformatted(label);
|
|
ImGui::PopStyleColor();
|
|
const bool clicked = ImGui::IsItemClicked();
|
|
if (ImGui::IsItemHovered()) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
const ImVec2 mn = ImGui::GetItemRectMin(), mx = ImGui::GetItemRectMax();
|
|
ImGui::GetWindowDrawList()->AddLine(ImVec2(mn.x, mx.y), ImVec2(mx.x, mx.y),
|
|
ui::material::OnSurface());
|
|
}
|
|
return clicked;
|
|
};
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
|
if (linkText(TR("wallet_recovered_open_folder")))
|
|
util::Platform::openFolder(util::Platform::getDragonXDataDir());
|
|
ImGui::SameLine(0, ui::Layout::spacingSm());
|
|
ImGui::TextDisabled("\xC2\xB7"); // middle dot separator
|
|
ImGui::SameLine(0, ui::Layout::spacingSm());
|
|
if (linkText(TR("empty_wallet_warning_dismiss"))) {
|
|
if (settings_) {
|
|
settings_->ackEmptyWalletWarn(settings_->getActiveWalletFile());
|
|
settings_->save();
|
|
}
|
|
show_empty_wallet_warning_ = false;
|
|
}
|
|
if (ImGui::IsItemHovered()) {
|
|
ImGui::BeginTooltip();
|
|
ImGui::PushTextWrapPos(ImGui::GetFontSize() * 22.0f);
|
|
ImGui::TextUnformatted(TR("empty_wallet_warning_dismiss_tip"));
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::EndTooltip();
|
|
}
|
|
|
|
ui::material::EndOverlayDialog();
|
|
}
|
|
|
|
// Auto-shown when the embedded node aborts on an unreadable block database — offers the one-click
|
|
// -reindex rebuild instead of leaving the wallet stuck on a silent zero balance.
|
|
void App::renderBlockDbReindexDialog()
|
|
{
|
|
if (!show_block_db_reindex_confirm_) return;
|
|
|
|
ui::material::OverlayDialogSpec ov;
|
|
ov.title = TR("block_db_reindex_title");
|
|
ov.p_open = &show_block_db_reindex_confirm_; // X / backdrop dismisses (offer remains; loop stays held)
|
|
ov.style = ui::material::OverlayStyle::BlurFloat;
|
|
ov.cardWidth = 540.0f;
|
|
ov.idSuffix = "blockdbreindex";
|
|
if (!ui::material::BeginOverlayDialog(ov)) return;
|
|
const float dp = ui::Layout::dpiScale();
|
|
|
|
ui::material::DialogWarningHeader(TR("block_db_reindex_warn"));
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
ImGui::TextWrapped("%s", TR("block_db_reindex_body"));
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
|
if (ui::material::TactileButton(TR("block_db_reindex_confirm"), ImVec2(260.0f * dp, 0))) {
|
|
reindexBlockDatabase(); // clears show_block_db_reindex_confirm_ + block_db_reindex_available_
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(110.0f * dp, 0))) {
|
|
show_block_db_reindex_confirm_ = false;
|
|
// Leave block_db_reindex_available_ set: the connect loop keeps HOLDING (no crash-restart storm)
|
|
// rather than looping into the same abort; the user can rebuild later from Settings.
|
|
}
|
|
ui::material::EndOverlayDialog();
|
|
}
|
|
|
|
void App::renderSwitchStopDaemonDialog()
|
|
{
|
|
const bool confirm = show_switch_stop_daemon_confirm_;
|
|
const bool progress = wallet_switch_dialog_open_.load();
|
|
if (!confirm && !progress) return;
|
|
|
|
const auto phase = static_cast<WalletSwitchPhase>(wallet_switch_phase_.load());
|
|
const bool failed = progress && phase == WalletSwitchPhase::Failed;
|
|
|
|
// Prettify a wallet filename for display (wallet.dat → "Default wallet", wallet-savings.dat → "savings").
|
|
auto pretty = [this](const std::string& f) -> std::string {
|
|
if (f.empty() || f == "wallet.dat") return TR("switch_progress_default_wallet");
|
|
std::string s = f;
|
|
if (s.size() > 4 && s.substr(s.size() - 4) == ".dat") s.resize(s.size() - 4);
|
|
if (s.rfind("wallet-ip-", 0) == 0) return TR("switch_progress_external_wallet");
|
|
if (s.rfind("wallet-", 0) == 0) s = s.substr(7);
|
|
return s;
|
|
};
|
|
const std::string toName = pretty(settings_ ? settings_->getActiveWalletFile() : std::string());
|
|
|
|
// Progress title carries the target wallet ("Switching wallet — savings"); keep the string alive.
|
|
std::string titleStr = confirm ? std::string(TR("switch_stopnode_title"))
|
|
: failed ? std::string(TR("switch_progress_failed_title"))
|
|
: std::string(TR("switch_progress_title")) + " — " + toName;
|
|
|
|
ui::material::OverlayDialogSpec ov;
|
|
ov.title = titleStr.c_str();
|
|
// A confirm can be dismissed via X/backdrop (= cancel); a running switch cannot (stay until it's up).
|
|
ov.p_open = confirm ? &show_switch_stop_daemon_confirm_ : nullptr;
|
|
ov.style = ui::material::OverlayStyle::BlurFloat;
|
|
ov.cardWidth = 520.0f; ov.idSuffix = "switchstopnode";
|
|
if (!ui::material::BeginOverlayDialog(ov)) {
|
|
if (confirm && !show_switch_stop_daemon_confirm_) pending_switch_wallet_file_.clear(); // X = cancel
|
|
return;
|
|
}
|
|
const float dp = ui::Layout::dpiScale();
|
|
|
|
if (confirm) {
|
|
ui::material::DialogWarningHeader(TR("switch_stopnode_warn"));
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
ImGui::TextWrapped("%s", TR("switch_stopnode_body"));
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
|
if (ui::material::TactileButton(TR("switch_stopnode_confirm"), ImVec2(200.0f * dp, 0))) {
|
|
const std::string w = pending_switch_wallet_file_;
|
|
pending_switch_wallet_file_.clear();
|
|
show_switch_stop_daemon_confirm_ = false;
|
|
switchToWallet(w, /*stopDaemonConfirmed=*/true); // opens the live-progress phase below
|
|
}
|
|
ImGui::SameLine();
|
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(110.0f * dp, 0))) {
|
|
pending_switch_wallet_file_.clear();
|
|
show_switch_stop_daemon_confirm_ = false;
|
|
}
|
|
} else if (failed) {
|
|
// The title already says "Wallet switch failed" — put the actual reason in the warning header
|
|
// rather than repeating the title.
|
|
ui::material::DialogWarningHeader(wallet_switch_error_.c_str());
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
|
// If the node reported the target wallet as corrupt, offer a one-click -salvagewallet repair
|
|
// (retries the switch to that wallet with recovery enabled).
|
|
if (switch_wallet_corrupt_.load()) {
|
|
if (ui::material::TactileButton(TR("switch_corrupt_repair"), ImVec2(240.0f * dp, 0))) {
|
|
const std::string target = wallet_switch_target_file_;
|
|
switchToWallet(target, /*stopDaemonConfirmed=*/true, /*salvage=*/true); // reopens the progress modal
|
|
}
|
|
ImGui::SameLine();
|
|
}
|
|
if (ui::material::TactileButton(TR("close"), ImVec2(110.0f * dp, 0))) {
|
|
wallet_switch_dialog_open_.store(false);
|
|
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::None));
|
|
wallet_switch_error_.clear();
|
|
}
|
|
} else {
|
|
// Live progress, kept redrawing by isWalletSwitchInProgress().
|
|
// Source context (the target is already in the title).
|
|
ImGui::PushFont(ui::material::Type().caption());
|
|
ImGui::TextDisabled("%s %s", TR("switch_progress_from_label"), pretty(wallet_switch_prev_file_).c_str());
|
|
ImGui::PopFont();
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
|
|
|
// Primary status + detail. During Reconnecting the node is loading the block index / verifying /
|
|
// rescanning — surface its live init stage (state_.warmup_status/description) instead of a bare
|
|
// "Reconnecting…", so the ~30-60s wait shows real progress.
|
|
std::string primary, detail;
|
|
if (phase == WalletSwitchPhase::Stopping) {
|
|
primary = TR("switch_progress_stopping");
|
|
detail = TR("switch_progress_hint");
|
|
} else if (phase == WalletSwitchPhase::Starting) {
|
|
primary = TR("switch_progress_starting");
|
|
} else { // Reconnecting
|
|
if (!state_.warmup_status.empty()) { primary = state_.warmup_status; detail = state_.warmup_description; }
|
|
else primary = TR("switch_progress_reconnecting");
|
|
}
|
|
ui::material::Type().text(ui::material::TypeStyle::Subtitle1,
|
|
(primary + ui::material::LoadingDots()).c_str());
|
|
if (!detail.empty()) {
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
ImGui::PushFont(ui::material::Type().body2());
|
|
ImGui::TextWrapped("%s", detail.c_str());
|
|
ImGui::PopFont();
|
|
}
|
|
|
|
// Elapsed timer — captured on the first progress frame (reset per switch in switchToWallet).
|
|
if (wallet_switch_started_time_ <= 0.0) wallet_switch_started_time_ = ImGui::GetTime();
|
|
const int el = static_cast<int>(ImGui::GetTime() - wallet_switch_started_time_);
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
|
ImGui::PushFont(ui::material::Type().caption());
|
|
ImGui::TextDisabled("%s %d:%02d", TR("switch_progress_elapsed"), el / 60, el % 60);
|
|
ImGui::PopFont();
|
|
|
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
|
// Escape hatch — the switch keeps running in the background (a toast reports the result).
|
|
if (ui::material::TactileButton(TR("switch_progress_background"), ImVec2(220.0f * dp, 0)))
|
|
wallet_switch_dialog_open_.store(false);
|
|
}
|
|
|
|
ui::material::EndOverlayDialog();
|
|
}
|
|
|
|
void App::refreshNow()
|
|
{
|
|
// Trigger immediate refresh on all categories
|
|
network_refresh_.markImmediateRefresh();
|
|
noteWalletMutation(); // the user asked: the change-gated scans run, the balance too
|
|
requestForcedBalanceRefresh();
|
|
markTransactionsDirty(); // Force transaction list update
|
|
markAddressesDirty(); // Force address/balance update
|
|
last_tx_block_height_ = -1; // Reset tx cache
|
|
// A full receive re-scan (explicit, so it runs back to back), but keep the sent sweep's watermark:
|
|
// every press would otherwise cost a whole-wallet z_listsentbyaddress.
|
|
invalidateShieldedHistoryScanProgress(true, /*keepSentScan=*/true);
|
|
address_retry_not_before_ = {}; // the user asked: an earlier failed enumeration's hold doesn't apply
|
|
// While the node catches up the wallet scans wait (they would slow it down): say so. (Not in the short
|
|
// settle window after it caught up: the status bar already says synced, and they run in a moment.)
|
|
if (rpc_ && rpc_->isConnected() && state_.sync.syncing)
|
|
ui::Notifications::instance().info(TR("refresh_waits_for_sync"));
|
|
}
|
|
|
|
void App::skipDaemonOutputBacklog()
|
|
{
|
|
// update() keeps running while minimized, but its output parser (the Fast block) skips while the wallet
|
|
// is locked, so daemon_output_offset_ stops advancing and a backlog of daemon output can pile up.
|
|
// Parsing it all at once on restore would replay a background
|
|
// witness rebuild's progress + completion in a single batch and fire a spurious "Blockchain rescan
|
|
// complete" toast. Advance the offset to the current end so only NEW (post-restore) output is parsed.
|
|
// A genuine user-initiated rescan still surfaces completion via the getrescaninfo monitor.
|
|
if (daemon_controller_ && daemon_controller_->isRunning()) {
|
|
// Advances daemon_output_offset_ to the end. The busy tracker still sees it: whether the node is
|
|
// mid-rebuild must not depend on whether the window was minimized while the rebuild started.
|
|
feedDaemonBusy(daemon_controller_->outputSince(daemon_output_offset_));
|
|
}
|
|
}
|
|
|
|
void App::handlePaymentURI(const std::string& uri)
|
|
{
|
|
auto payment = util::parsePaymentURI(uri);
|
|
|
|
if (!payment.valid) {
|
|
ui::Notifications::instance().error(std::string(TR("appx_invalid_payment_uri_prefix")) + 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 = TR("appx_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]);
|
|
}
|
|
|
|
std::string App::getDaemonStatus() const
|
|
{
|
|
std::lock_guard<std::mutex> lk(daemon_status_mutex_);
|
|
return daemon_status_; // a copy — safe to use after the lock is released
|
|
}
|
|
|
|
void App::setDaemonStatus(std::string s)
|
|
{
|
|
std::lock_guard<std::mutex> lk(daemon_status_mutex_);
|
|
daemon_status_ = std::move(s);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// The startup job may still be writing/copying/verifying the node files. Never launch the node over
|
|
// half-written files and never block the UI thread on the job: on the UI thread remember the request
|
|
// (pollStartupFiles() replays it) and report true ("starting") so every caller keeps its retry path
|
|
// instead of raising a failure — NOTE: true here does NOT mean a process was launched; on a worker,
|
|
// wait for the job (ends at shutdown).
|
|
if (startup_files_) {
|
|
switch (resources::daemonStartGate(startup_files_->isRunning(),
|
|
std::this_thread::get_id() == ui_thread_id_)) {
|
|
case resources::DaemonStartGate::Defer:
|
|
setDaemonStatus(TR("sb_waiting_daemon")); // neutral: the job may be a copy / verify / miner refresh
|
|
daemon_start_after_files_ = true;
|
|
return true;
|
|
case resources::DaemonStartGate::Wait:
|
|
setDaemonStatus(TR("sb_waiting_daemon"));
|
|
while (!startup_files_->waitUntilDone(100)) {
|
|
if (shutting_down_) return false;
|
|
}
|
|
// The job ends BECAUSE of shutdown (cancelAll): don't fall into the uncancellable extraction/launch.
|
|
if (shutting_down_ || startup_files_->endedCancelled()) return false;
|
|
break;
|
|
case resources::DaemonStartGate::Proceed:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 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");
|
|
setDaemonStatus(TR("sb_extracting_sapling"));
|
|
if (!resources::extractEmbeddedResources()) {
|
|
setDaemonStatus(TR("sb_daemon_extract_failed"));
|
|
DEBUG_LOGF("[ERROR] extractEmbeddedResources() failed — disk full or permission denied?\n");
|
|
return false;
|
|
}
|
|
|
|
// Check again after extraction
|
|
if (!rpc::Connection::verifySaplingParams()) {
|
|
setDaemonStatus(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: params bundled alongside the executable (zip / AppImage / .app). The startup job
|
|
// normally did this already; this repairs a failed or corrupt copy. replaceExisting: we only get
|
|
// here because verification FAILED, so an existing daemon-dir param is suspect (the old
|
|
// `if (exists) continue` kept a truncated param forever).
|
|
const std::string daemon_dir = resources::getDaemonDirectory();
|
|
if (!util::Platform::getExecutableDirectory().empty()) {
|
|
std::string dirErr;
|
|
if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
|
|
setDaemonStatus(dirErr);
|
|
DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
|
|
return false;
|
|
}
|
|
}
|
|
// At most once per session (the startup job or an earlier start may have tried it): a still-failing
|
|
// verify must not repeat this ~51.5 MB copy on every 5 s connect retry on the UI thread.
|
|
bool repaired = false;
|
|
if (!(startup_files_ && startup_files_->paramRepairTried.exchange(true))) {
|
|
const auto rep = resources::copyBundledFiles(resources::bundleSearchDirs(), daemon_dir,
|
|
resources::saplingParamFileNames(), /*replaceExisting=*/true);
|
|
repaired = rep.copied > 0 && rpc::Connection::verifySaplingParams();
|
|
}
|
|
if (repaired) {
|
|
DEBUG_LOGF("Sapling params copied from exe directory successfully\n");
|
|
} else {
|
|
setDaemonStatus(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 the daemon binaries are copied from the bundle to the daemon directory even if the
|
|
// Sapling params already existed (stat-only no-op when present; the startup job normally did it). An absent
|
|
// source is optional; only a real copy error counts.
|
|
{
|
|
const std::string daemon_dir = resources::getDaemonDirectory();
|
|
if (!util::Platform::getExecutableDirectory().empty()) {
|
|
std::string dirErr;
|
|
if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
|
|
setDaemonStatus(dirErr);
|
|
DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
|
|
return false;
|
|
}
|
|
static const std::vector<std::string> kExtraFiles = { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" };
|
|
const auto rep = resources::copyBundledFiles(resources::bundleSearchDirs(), daemon_dir, kExtraFiles,
|
|
/*replaceExisting=*/false);
|
|
if (!rep.ok) {
|
|
char buf[512];
|
|
snprintf(buf, sizeof(buf), TR("sb_daemon_files_failed"), daemon_dir.c_str());
|
|
setDaemonStatus(buf);
|
|
DEBUG_LOGF("[ERROR] One or more daemon files failed to copy to %s\n", daemon_dir.c_str());
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
// NB: this callback runs on EmbeddedDaemon's monitor thread — write via the locked setter.
|
|
switch (state) {
|
|
case daemon::EmbeddedDaemon::State::Starting:
|
|
setDaemonStatus(TR("sb_starting_daemon"));
|
|
break;
|
|
case daemon::EmbeddedDaemon::State::Running:
|
|
setDaemonStatus(TR("sb_dragonxd_running"));
|
|
break;
|
|
case daemon::EmbeddedDaemon::State::Stopping:
|
|
setDaemonStatus(TR("sb_dragonxd_stopping"));
|
|
break;
|
|
case daemon::EmbeddedDaemon::State::Stopped:
|
|
setDaemonStatus(TR("sb_dragonxd_stopped"));
|
|
break;
|
|
case daemon::EmbeddedDaemon::State::Error:
|
|
setDaemonStatus("Error: " + msg);
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Only a process we actually launched starts a new story: a start that finds a node already running (and
|
|
// launches nothing) must not wipe what the tracker knows about that node from its debug.log.
|
|
const bool launched = daemon_controller_->start(settings_.get());
|
|
if (launched) resetDaemonBusy(); // a new process: whatever the last one was doing is over
|
|
return launched;
|
|
}
|
|
|
|
// Drives one stop session for DaemonController::executeDaemonStop: a dedicated RPC client for "stop" (not
|
|
// rpc_, which another thread may hold, and whose getinfo probe needs the daemon's cs_main), the owned
|
|
// process's exit / signal / CPU, and the shutdown screen's choices.
|
|
class AppStopRuntime final : public daemon::DaemonController::StopRuntime {
|
|
public:
|
|
using DC = daemon::DaemonController;
|
|
|
|
AppStopRuntime(App& app, daemon::EmbeddedDaemon* owned, std::vector<rpc::ConnectionConfig> credentials,
|
|
daemon::EmbeddedDaemon::OwnershipRecord recorded = {})
|
|
: app_(app), owned_(owned), creds_(std::move(credentials)), recorded_(std::move(recorded)),
|
|
t0_(std::chrono::steady_clock::now())
|
|
{
|
|
std::lock_guard<std::mutex> lk(app_.stop_client_mutex_);
|
|
app_.active_stop_client_ = &client_;
|
|
}
|
|
~AppStopRuntime() override
|
|
{
|
|
std::lock_guard<std::mutex> lk(app_.stop_client_mutex_);
|
|
app_.active_stop_client_ = nullptr;
|
|
}
|
|
|
|
double nowSec() override
|
|
{
|
|
return std::chrono::duration<double>(std::chrono::steady_clock::now() - t0_).count();
|
|
}
|
|
bool processAlive() override
|
|
{
|
|
if (owned_) return owned_->pollOwnedProcess();
|
|
// The node an earlier session of ours left running: that exact process (pid + start time).
|
|
if (recorded_.pid > 0)
|
|
return daemon::EmbeddedDaemon::processStartToken(recorded_.pid) == recorded_.startToken;
|
|
// Not ours: we can only watch for it to go (port and process), never wait on or signal it.
|
|
return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
|
|
}
|
|
daemon::StopSendResult trySendStop() override
|
|
{
|
|
if (creds_.empty()) return daemon::StopSendResult::Other;
|
|
if (!clientOpen_) {
|
|
const auto& c = creds_[credIndex_];
|
|
clientOpen_ = client_.open(c.host, c.port, c.rpcuser, c.rpcpassword, c.use_tls);
|
|
if (!clientOpen_) return daemon::StopSendResult::Other;
|
|
}
|
|
const rpc::RpcFailureKind kind = client_.trySendStop(2);
|
|
const daemon::StopSendResult r = daemon::stopSendResultFromKind(kind);
|
|
++attempts_;
|
|
if (attempts_ == 1 || r != lastLogged_) { // log changes, not every retry
|
|
DEBUG_LOGF("[stop] attempt %d at %.1fs: %s (%s)\n", attempts_, nowSec(),
|
|
daemon::stopSendResultName(r), rpc::rpcFailureKindName(kind));
|
|
lastLogged_ = r;
|
|
}
|
|
return r;
|
|
}
|
|
bool nextCredentials() override
|
|
{
|
|
if (credIndex_ + 1 >= creds_.size()) return false;
|
|
++credIndex_;
|
|
clientOpen_ = false;
|
|
DEBUG_LOGF("[stop] HTTP 401 — retrying with the next credential source (%zu of %zu)\n",
|
|
credIndex_ + 1, creds_.size());
|
|
return true;
|
|
}
|
|
bool sendGracefulSignal() override { return owned_ && owned_->signalGracefulStop(); }
|
|
double cpuSeconds() override
|
|
{
|
|
usage_ = owned_ ? owned_->ownedResourceUsage() : daemon::EmbeddedDaemon::ResourceUsage{};
|
|
return usage_.cpuSeconds;
|
|
}
|
|
double ioBytes() override { return usage_.ioBytes; } // sampled with cpuSeconds()
|
|
bool rebuildActive() override { return app_.node_rebuilding_.load(); } // the node's own log says so
|
|
daemon::StopUserChoice userChoice() override
|
|
{
|
|
return static_cast<daemon::StopUserChoice>(app_.stop_user_choice_.load());
|
|
}
|
|
void offerForceKill(bool offered) override
|
|
{
|
|
DEBUG_LOGF("[stop] %s\n", offered ? "no CPU/I-O progress for a long time: offering Kill"
|
|
: "progress resumed: Kill offer withdrawn");
|
|
app_.stop_kill_offered_ = offered;
|
|
}
|
|
void forceKill() override
|
|
{
|
|
DEBUG_LOGF("[stop] user chose Kill after %.0fs\n", nowSec());
|
|
if (owned_) owned_->forceKillOwnedProcess();
|
|
}
|
|
void setStatus(const DC::StopStatus& s) override
|
|
{
|
|
if (s.delivered && !loggedDelivered_) {
|
|
DEBUG_LOGF("[stop] stop delivered at %.1fs (%s)\n", s.elapsedSec,
|
|
s.signalled ? "SIGTERM to the process group" : daemon::stopSendResultName(s.lastSend));
|
|
loggedDelivered_ = true;
|
|
}
|
|
std::string text = TR(s.delivered ? "shutdown_stop_delivered"
|
|
: s.heard ? "shutdown_stop_queued"
|
|
: "shutdown_stop_asking");
|
|
if (s.cpuPercent >= 0) {
|
|
char buf[64];
|
|
snprintf(buf, sizeof(buf), TR("shutdown_stop_cpu_suffix"), static_cast<int>(s.cpuPercent + 0.5));
|
|
text += buf;
|
|
}
|
|
app_.shutdown_status_ = text;
|
|
app_.stop_idle_minutes_ = static_cast<int>(s.idleSec / 60.0);
|
|
}
|
|
|
|
private:
|
|
App& app_;
|
|
daemon::EmbeddedDaemon* owned_ = nullptr;
|
|
std::vector<rpc::ConnectionConfig> creds_;
|
|
daemon::EmbeddedDaemon::OwnershipRecord recorded_; // pid > 0: our earlier session's detached node
|
|
size_t credIndex_ = 0;
|
|
rpc::RPCClient client_;
|
|
bool clientOpen_ = false;
|
|
int attempts_ = 0;
|
|
daemon::StopSendResult lastLogged_ = daemon::StopSendResult::Other;
|
|
bool loggedDelivered_ = false;
|
|
daemon::EmbeddedDaemon::ResourceUsage usage_;
|
|
std::chrono::steady_clock::time_point t0_;
|
|
};
|
|
|
|
class AppStopTaskContext final : public daemon::DaemonController::LifecycleTaskContext {
|
|
public:
|
|
AppStopTaskContext(App& app, bool watchShutdown) : app_(app), watchShutdown_(watchShutdown) {}
|
|
bool cancelled() const override { return false; }
|
|
bool shuttingDown() const override { return watchShutdown_ && app_.shutting_down_.load(); }
|
|
void sleepForMs(int ms) override { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); }
|
|
|
|
private:
|
|
App& app_;
|
|
bool watchShutdown_ = false;
|
|
};
|
|
|
|
void App::chooseStopAction(daemon::StopUserChoice choice)
|
|
{
|
|
stop_user_choice_ = static_cast<int>(choice);
|
|
if (choice == daemon::StopUserChoice::Detach) {
|
|
// End a pending stop attempt now rather than after its timeout.
|
|
std::lock_guard<std::mutex> lk(stop_client_mutex_);
|
|
if (active_stop_client_) active_stop_client_->requestAbort();
|
|
}
|
|
}
|
|
|
|
daemon::StopOutcome App::stopEmbeddedDaemon(StopMode mode, StopScope scope)
|
|
{
|
|
if (scope == StopScope::Policy) {
|
|
const bool mayStopExternal = mayStopExternalNode();
|
|
if (!daemon_controller_) {
|
|
if (!mayStopExternal) return daemon::StopOutcome::Exited; // nothing we may stop
|
|
} else {
|
|
auto shutdownDecision = daemon_controller_->shutdownDecision(false, mayStopExternal);
|
|
if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) {
|
|
DEBUG_LOGF("stopEmbeddedDaemon: %s — skipping\n", shutdownDecision.logReason);
|
|
return daemon::StopOutcome::StillRunning;
|
|
}
|
|
}
|
|
}
|
|
|
|
// One stop at a time: a second caller (say the shutdown thread while a restart task is stopping the
|
|
// node) waits for the first. Background stops let go as soon as the app starts quitting, so the
|
|
// shutdown never waits long behind one.
|
|
std::lock_guard<std::mutex> single(stop_mutex_);
|
|
daemon::EmbeddedDaemon* d = daemon_controller_ ? daemon_controller_->daemon() : nullptr;
|
|
// beginStop() also marks the stop as requested (an early exit is not a crash) and makes this session
|
|
// the only reaper. Only a process we launched and still hold is "owned": a lock-gated start, an
|
|
// external node and a node we already let go of are not, and are never waited on, signalled or killed.
|
|
const bool owned = d && d->beginStop();
|
|
if (d && !owned) d->endStop(/*keepWatching=*/false);
|
|
// Not launched by this session, but by an earlier one that left it running: we wait for that exact
|
|
// process like our own (no time limit when quitting), but still never signal or kill it.
|
|
daemon::EmbeddedDaemon::OwnershipRecord recorded;
|
|
const bool ours = !owned && daemon::EmbeddedDaemon::liveOwnershipRecord(
|
|
daemon::EmbeddedDaemon::ownershipRecordPath(), &recorded);
|
|
|
|
if (!owned && !daemon::EmbeddedDaemon::isRpcPortInUse()) {
|
|
// Nothing listening for a stop: either it is gone, or it is another dragonxd we can't talk to (one
|
|
// still finishing its own shutdown trips the start() lock gate this way). Leave it be.
|
|
const bool running = daemon::EmbeddedDaemon::isDaemonProcessRunning();
|
|
DEBUG_LOGF("stopEmbeddedDaemon: no node of ours and no RPC listener%s\n",
|
|
running ? " — another dragonxd is running; leaving it alone" : "");
|
|
return running ? daemon::StopOutcome::StillRunning : daemon::StopOutcome::Exited;
|
|
}
|
|
|
|
// Credentials for the stop: our own node's conf (and, if those are stale, our last working
|
|
// connection, then the .cookie); a node we don't own, the exact credentials we connected with.
|
|
std::vector<rpc::ConnectionConfig> creds;
|
|
const rpc::ConnectionConfig detected = rpc::Connection::autoDetectConfig();
|
|
const bool haveSaved = !saved_config_.rpcuser.empty() || !saved_config_.rpcpassword.empty();
|
|
if (owned) {
|
|
creds.push_back(detected);
|
|
if (haveSaved && (saved_config_.rpcuser != detected.rpcuser ||
|
|
saved_config_.rpcpassword != detected.rpcpassword || saved_config_.port != detected.port))
|
|
creds.push_back(saved_config_);
|
|
rpc::ConnectionConfig cookie;
|
|
if (rpc::Connection::buildCookieAuthConfig(detected, cookie)) creds.push_back(cookie);
|
|
} else {
|
|
creds.push_back(haveSaved ? saved_config_ : detected);
|
|
}
|
|
|
|
daemon::DaemonController::StopPolicy policy;
|
|
policy.owned = owned;
|
|
#ifndef _WIN32
|
|
policy.gracefulSignal = owned; // dragonxd treats SIGTERM like stop; Windows has no such channel
|
|
#endif
|
|
switch (mode) {
|
|
case StopMode::AppShutdown:
|
|
policy.userChoices = true;
|
|
policy.maxWaitSec = (owned || ours) ? 0.0 : 120.0; // a node we don't own: watch it go for up to 2 min
|
|
break;
|
|
case StopMode::Background:
|
|
policy.maxWaitSec = 120.0;
|
|
policy.abandonOnShutdown = true;
|
|
stop_user_choice_ = 0;
|
|
break;
|
|
case StopMode::Fallback:
|
|
policy.maxWaitSec = 20.0;
|
|
stop_user_choice_ = 0;
|
|
break;
|
|
}
|
|
|
|
DEBUG_LOGF("stopEmbeddedDaemon: %s node, %s\n",
|
|
owned ? "our own" : ours ? "our earlier session's (left running)" : "an external",
|
|
mode == StopMode::AppShutdown
|
|
? (policy.maxWaitSec > 0 ? "quitting (up to 120s for a node we don't own; never killed)"
|
|
: "quitting (no time limit; the user may leave it running)")
|
|
: mode == StopMode::Background ? "background (up to 120s, never killed)"
|
|
: "fallback (up to 20s, never killed)");
|
|
stop_kill_offered_ = false;
|
|
stop_session_active_ = true;
|
|
daemon::StopOutcome outcome;
|
|
{
|
|
AppStopRuntime runtime(*this, owned ? d : nullptr, std::move(creds),
|
|
ours ? recorded : daemon::EmbeddedDaemon::OwnershipRecord{});
|
|
AppStopTaskContext task(*this, policy.abandonOnShutdown);
|
|
outcome = daemon::DaemonController::executeDaemonStop(policy, runtime, task);
|
|
}
|
|
stop_session_active_ = false;
|
|
stop_kill_offered_ = false;
|
|
// Still running after a background wait: keep watching it (its exit is booked as a clean stop). Left
|
|
// running on purpose ("Leave running"), exited or killed: let go of it.
|
|
if (owned) d->endStop(/*keepWatching=*/outcome == daemon::StopOutcome::StillRunning);
|
|
if (outcome == daemon::StopOutcome::Exited || outcome == daemon::StopOutcome::Killed)
|
|
daemon::EmbeddedDaemon::liveOwnershipRecord(daemon::EmbeddedDaemon::ownershipRecordPath()); // drops it if gone
|
|
DEBUG_LOGF("stopEmbeddedDaemon: %s\n",
|
|
outcome == daemon::StopOutcome::Exited ? "the node exited"
|
|
: outcome == daemon::StopOutcome::Detached ? "left running at the user's request"
|
|
: outcome == daemon::StopOutcome::Killed ? "killed at the user's request"
|
|
: "still running (stopped waiting; never killed)");
|
|
return outcome;
|
|
}
|
|
|
|
bool App::isEmbeddedDaemonRunning() const
|
|
{
|
|
return daemon_controller_ && daemon_controller_->isRunning();
|
|
}
|
|
|
|
bool App::stopDaemonForWalletSwitch()
|
|
{
|
|
// Stop whatever node we're on: ours, or the adopted/external one we're connected to. "Owned" can't be
|
|
// read from externalDaemonDetected() (it's latched only inside EmbeddedDaemon::start(), so a node we
|
|
// direct-connected to reads as ours); StopScope::Connected covers both — our process handle when we have
|
|
// one, else a graceful "stop" with the exact credentials we connected with (only our node accepts them,
|
|
// so a foreign dragonxd is a safe no-op). Bounded, never killed, abandoned when the app starts quitting.
|
|
const daemon::StopOutcome outcome = stopEmbeddedDaemon(StopMode::Background, StopScope::Connected);
|
|
// CRITICAL: the caller swaps wallet.dat or starts a replacement next, so it may go ahead only once the node
|
|
// has fully EXITED. dragonxd unbinds RPC early in its shutdown but keeps wallet.dat and the datadir lock
|
|
// until the process exits, which can take minutes. A wait abandoned because the app is quitting proves
|
|
// nothing: report "not stopped" then too.
|
|
if (outcome != daemon::StopOutcome::Exited || shutting_down_) return false;
|
|
auto stillUp = [] {
|
|
return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
|
|
};
|
|
for (int i = 0; i < 200 && stillUp() && !shutting_down_; ++i) // up to 20s for the port to free
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
return !shutting_down_ && !stillUp();
|
|
}
|
|
|
|
void App::rescanBlockchain()
|
|
{
|
|
if (!supportsFullNodeLifecycleActions()) {
|
|
ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
|
|
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(TR("appx_blockchain_maintenance_in_progress"));
|
|
return;
|
|
}
|
|
// Don't race a wallet switch / seed-adopt / encryption restart, which drive their own daemon stop/start.
|
|
if (daemon_restarting_) {
|
|
ui::Notifications::instance().warning(TR("appx_node_busy_restarting"));
|
|
return;
|
|
}
|
|
if (refuseIfNodeBusy()) return;
|
|
|
|
DEBUG_LOGF("[App] Starting blockchain rescan - stopping daemon first\n");
|
|
ui::Notifications::instance().info(TR("appx_restarting_daemon_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;
|
|
user_initiated_rescan_ = true; // user-triggered rescan → its completion should toast
|
|
state_.sync.rescan_progress = 0.0f;
|
|
state_.sync.rescan_status = decision.status;
|
|
markTransactionsDirty();
|
|
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_);
|
|
if (daemon_controller_->executeLifecycleOperation(decision, runtime, context).stopFailed)
|
|
pending_stop_failed_.fetch_or(kStopFailedNotify | kStopFailedUndoRescan);
|
|
});
|
|
}
|
|
|
|
void App::repairWallet()
|
|
{
|
|
if (!supportsFullNodeLifecycleActions()) {
|
|
ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
|
|
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(TR("appx_blockchain_maintenance_in_progress"));
|
|
return;
|
|
}
|
|
if (daemon_restarting_) {
|
|
ui::Notifications::instance().warning(TR("appx_node_busy_restarting"));
|
|
return;
|
|
}
|
|
if (refuseIfNodeBusy()) return;
|
|
|
|
DEBUG_LOGF("[App] Starting wallet repair (-zapwallettxes=2) - stopping daemon first\n");
|
|
ui::Notifications::instance().info(TR("appx_restarting_daemon_zapwallettxes"));
|
|
|
|
// -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;
|
|
user_initiated_rescan_ = true; // user-triggered repair (implies rescan) → completion should toast
|
|
state_.sync.rescan_progress = 0.0f;
|
|
state_.sync.rescan_status = decision.status;
|
|
markTransactionsDirty();
|
|
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_);
|
|
if (daemon_controller_->executeLifecycleOperation(decision, runtime, context).stopFailed)
|
|
pending_stop_failed_.fetch_or(kStopFailedNotify | kStopFailedUndoRescan);
|
|
});
|
|
}
|
|
|
|
void App::reinstallBundledDaemon()
|
|
{
|
|
if (!supportsFullNodeLifecycleActions()) {
|
|
ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
|
|
return;
|
|
}
|
|
if (!resources::getBundledDaemonInfo().available) {
|
|
ui::Notifications::instance().warning(TR("appx_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(TR("appx_no_embedded_daemon_to_install"));
|
|
return;
|
|
}
|
|
|
|
if (async_tasks_.isRunning("reinstall-daemon")) {
|
|
ui::Notifications::instance().warning(TR("appx_daemon_reinstall_in_progress"));
|
|
return;
|
|
}
|
|
// Don't race a wallet switch / seed-adopt / encryption restart, which drive their own daemon stop/start.
|
|
if (daemon_restarting_) {
|
|
ui::Notifications::instance().warning(TR("appx_node_busy_restarting"));
|
|
return;
|
|
}
|
|
if (refuseIfNodeBusy()) return;
|
|
|
|
DEBUG_LOGF("[App] Reinstalling bundled daemon binary — stopping daemon first\n");
|
|
ui::Notifications::instance().info(TR("appx_installing_bundled_daemon"));
|
|
|
|
// This task owns the node for its whole run: it starts the node itself once the new files are in place, so
|
|
// drop a start deferred behind the startup files job and gate tryConnect() + the pollStartupFiles() replay
|
|
// (either could otherwise launch the OLD binary while the new one is being written).
|
|
daemon_start_after_files_ = false;
|
|
daemon_restarting_ = true;
|
|
try {
|
|
async_tasks_.submit("reinstall-daemon", [this](const util::AsyncTaskManager::Token& token) {
|
|
struct Rearm { std::atomic<bool>& f; ~Rearm() { f = false; } } rearm{daemon_restarting_}; // every exit
|
|
AppDaemonLifecycleRuntime runtime(*this);
|
|
daemon::AsyncLifecycleTaskContext ctx(token, shutting_down_);
|
|
// Stop the running node so its binary is free: ours, or the external/leftover dragonxd we're attached
|
|
// to (it would keep the RPC port and the fresh daemon couldn't start). Bounded, never killed.
|
|
stopEmbeddedDaemon(StopMode::Background, StopScope::Connected);
|
|
// Wait (bounded, ~120s) for the old node's PROCESS to exit, not just its RPC port: dragonxd unbinds
|
|
// RPC first and can take a minute or more to flush and close its wallet. Until it exits its exe is
|
|
// locked on Windows (the overwrite would fail) and it holds the datadir lock (a restart would fail).
|
|
auto stillUp = [] {
|
|
return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
|
|
};
|
|
for (int i = 0; i < 1200 && stillUp() && !ctx.cancelled() && !ctx.shuttingDown(); ++i) {
|
|
ctx.sleepForMs(100);
|
|
}
|
|
if (ctx.cancelled() || ctx.shuttingDown()) return;
|
|
if (stillUp()) {
|
|
DEBUG_LOGF("[App] Reinstall bundled daemon: aborted — a dragonxd process is still running; "
|
|
"binaries left untouched and no new node started\n");
|
|
pending_reinstall_node_busy_.store(true, std::memory_order_relaxed); // told on the UI thread
|
|
return;
|
|
}
|
|
// 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();
|
|
}
|
|
});
|
|
} catch (...) {
|
|
daemon_restarting_ = false; // no task ran: re-arm the reconnect gate
|
|
throw;
|
|
}
|
|
}
|
|
|
|
void App::deleteBlockchainData()
|
|
{
|
|
if (!supportsFullNodeLifecycleActions()) {
|
|
ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
|
|
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(TR("appx_blockchain_maintenance_in_progress"));
|
|
return;
|
|
}
|
|
if (refuseIfNodeBusy()) return;
|
|
|
|
DEBUG_LOGF("[App] Deleting blockchain data - stopping daemon first\n");
|
|
ui::Notifications::instance().info(TR("appx_stopping_daemon_deleting_blockchain"));
|
|
|
|
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);
|
|
if (result.stopFailed) {
|
|
DEBUG_LOGF("[App] Blockchain data NOT deleted: the node did not exit\n");
|
|
pending_stop_failed_.fetch_or(kStopFailedNotify);
|
|
return;
|
|
}
|
|
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()
|
|
{
|
|
// A bootstrap download stops the node on purpose: drop a start deferred while the startup files were
|
|
// being prepared, or pollStartupFiles() would launch the node into the half-extracted chain data.
|
|
daemon_start_after_files_ = false;
|
|
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
|
|
{
|
|
// Never probes on the calling (UI) thread. The probe (a /proc scan on Linux, a forked `ps` on macOS, a
|
|
// process snapshot for an external daemon) runs on daemon_mem_sampler_'s thread. This returns the last
|
|
// completed sample (0 until the first lands, tens of ms after the first ask) and asks for a fresh one
|
|
// when the last request is older than 3 s.
|
|
constexpr auto kMaxAge = std::chrono::milliseconds(3000);
|
|
const auto now = std::chrono::steady_clock::now();
|
|
if (daemon_mem_sampler_.due(now, kMaxAge)) {
|
|
util::BackgroundSampler::Probe probe;
|
|
if (daemon_controller_ && daemon_controller_->isRunning()) {
|
|
// The embedded daemon we own: its tracked process group first, a process scan if that reads 0.
|
|
daemon_mem_diag_ = "embedded";
|
|
probe = [own = daemon_controller_->memoryProbe()]() {
|
|
const double mb = own ? own() : 0.0;
|
|
return mb > 0.0 ? mb : util::Platform::getDaemonMemoryUsageMB();
|
|
};
|
|
} else {
|
|
daemon_mem_diag_ = "process scan";
|
|
probe = []() { return util::Platform::getDaemonMemoryUsageMB(); }; // external daemon
|
|
}
|
|
daemon_mem_sampler_.requestIfStale(std::move(probe), now, kMaxAge);
|
|
}
|
|
return daemon_mem_sampler_.value();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Shutdown
|
|
// ============================================================================
|
|
|
|
void App::requestQuit()
|
|
{
|
|
beginShutdown();
|
|
}
|
|
|
|
void App::beginShutdown()
|
|
{
|
|
// Only start shutdown once
|
|
if (shutting_down_) return;
|
|
|
|
// Guard: don't silently discard an in-progress witness-cache rebuild. If we're about to stop the
|
|
// daemon while it's rebuilding (stopping now forces a multi-minute rebuild on the next launch),
|
|
// defer shutdown and let render() show the confirm modal. The user's choice re-enters beginShutdown()
|
|
// with shutdown_confirmed_ set (and, for "keep node running", shutdown_keep_daemon_override_).
|
|
if (!shutdown_confirmed_ && shouldConfirmDaemonStop()) {
|
|
DEBUG_LOGF("beginShutdown: the node is %s — asking before stopping it\n",
|
|
node_rebuilding_ ? "rebuilding witnesses" : "too busy to answer RPC");
|
|
pending_shutdown_confirm_ = true;
|
|
// The modal only renders in a visible frame: a quit from the taskbar while minimized (or behind
|
|
// other windows) would otherwise wait on a prompt nobody can see.
|
|
if (raise_window_) raise_window_();
|
|
return; // NOT shutting down yet — the normal UI + modal keep rendering
|
|
}
|
|
|
|
shutting_down_ = true;
|
|
quit_requested_ = true;
|
|
shutdown_timer_ = 0.0f;
|
|
stop_user_choice_ = 0;
|
|
stop_kill_offered_ = false;
|
|
shutdown_start_time_ = std::chrono::steady_clock::now();
|
|
// Queue the final tx-history snapshot now so its write overlaps the daemon stop; shutdown() drains it.
|
|
storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::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");
|
|
// The wallet-switch task also drives daemon stop/start — let it finish before shutdown touches the
|
|
// daemon so it can't orphan a freshly-started dragonxd (it checks shutting_down_ before starting).
|
|
if (async_tasks_.isRunning("Switch wallet"))
|
|
async_tasks_.join("Switch 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();
|
|
abortRpcBusyCanary();
|
|
if (worker_) {
|
|
worker_->requestStop();
|
|
}
|
|
if (fast_worker_) {
|
|
fast_worker_->requestStop();
|
|
}
|
|
// HTTP lane: requestStop() alone would still run every queued GET to completion (RPCWorker::run drains its
|
|
// queue). The flag makes queued GETs return at once and aborts an in-flight transfer through libcurl's
|
|
// progress callback; shutdown() joins.
|
|
http_cancel_.store(true, std::memory_order_relaxed);
|
|
if (http_worker_) {
|
|
http_worker_->requestStop();
|
|
}
|
|
|
|
// Drain + join the mining-control thread FIRST so no async start/stop job runs while we tear the miner
|
|
// down here (avoids two threads driving xmrig_manager_ during shutdown). (M-03 cluster)
|
|
stopMiningControlThread();
|
|
|
|
// Stop xmrig pool miner before stopping the daemon
|
|
if (xmrig_manager_ && xmrig_manager_->isRunning()) {
|
|
shutdown_status_ = TR("appx_stopping_pool_miner");
|
|
xmrig_manager_->stop(3000);
|
|
}
|
|
|
|
DEBUG_LOGF("beginShutdown: starting (daemon_controller_=%s)\n",
|
|
daemon_controller_ ? "yes" : "no");
|
|
|
|
// No controller (this session never launched a node): disconnect only — unless the node is the one our
|
|
// earlier session left running, or stop_external_daemon is on. Worker join + RPC disconnect happen in
|
|
// shutdown().
|
|
const bool keepRunning = (settings_ && settings_->getKeepDaemonRunning()) || shutdown_keep_daemon_override_;
|
|
if (!daemon_controller_ && (keepRunning || !mayStopExternalNode())) {
|
|
DEBUG_LOGF("beginShutdown: no embedded daemon, disconnecting only\n");
|
|
shutdown_status_ = TR("appx_disconnecting");
|
|
if (settings_) {
|
|
settings_->save();
|
|
}
|
|
shutdown_complete_ = true;
|
|
return;
|
|
}
|
|
|
|
// Save settings now (safe to do on main thread)
|
|
if (settings_) {
|
|
settings_->save();
|
|
}
|
|
|
|
if (daemon_controller_) {
|
|
auto shutdownDecision = daemon_controller_->shutdownDecision(keepRunning, mayStopExternalNode());
|
|
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;
|
|
} else {
|
|
shutdown_status_ = TR("appx_sending_stop_command_to_daemon");
|
|
}
|
|
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_ = TR("appx_sending_stop_command_to_daemon");
|
|
// Deliver stop and wait for the node to exit: no time limit, never killed unasked. The shutdown
|
|
// screen offers "Leave running" (and "Kill" if the node goes idle), which end the wait at once.
|
|
stopEmbeddedDaemon(StopMode::AppShutdown);
|
|
|
|
DEBUG_LOGF("shutdown thread: daemon stopped, disconnecting RPC\n");
|
|
shutdown_status_ = TR("appx_cleaning_up");
|
|
|
|
DEBUG_LOGF("shutdown thread: complete\n");
|
|
shutdown_status_ = TR("appx_shutdown_complete");
|
|
shutdown_complete_ = true;
|
|
});
|
|
}
|
|
|
|
std::vector<std::string> App::tailDaemonDebugLog(int maxLines) const
|
|
{
|
|
std::vector<std::string> out;
|
|
if (maxLines <= 0) return out;
|
|
const std::string path = util::Platform::getDataDir() + "debug.log";
|
|
std::error_code ec;
|
|
const auto sz = std::filesystem::file_size(path, ec);
|
|
if (ec || sz == 0) return out;
|
|
std::ifstream f(path, std::ios::binary);
|
|
if (!f) return out;
|
|
|
|
// Read only the last ~16 KB — plenty for a handful of lines, cheap even for a multi-GB log.
|
|
const std::uintmax_t kTailBytes = 16 * 1024;
|
|
const std::uintmax_t start = sz > kTailBytes ? sz - kTailBytes : 0;
|
|
f.seekg(static_cast<std::streamoff>(start), std::ios::beg);
|
|
std::string chunk(static_cast<std::size_t>(sz - start), '\0');
|
|
f.read(&chunk[0], static_cast<std::streamsize>(chunk.size()));
|
|
chunk.resize(static_cast<std::size_t>(f.gcount()));
|
|
|
|
std::vector<std::string> lines;
|
|
std::string cur;
|
|
for (char c : chunk) {
|
|
if (c == '\n') { if (!cur.empty()) lines.push_back(cur); cur.clear(); }
|
|
else if (c != '\r') cur.push_back(c);
|
|
}
|
|
if (!cur.empty()) lines.push_back(cur);
|
|
// When we seeked into the middle of the file the first line is a fragment — drop it.
|
|
if (start > 0 && !lines.empty()) lines.erase(lines.begin());
|
|
if (static_cast<int>(lines.size()) > maxLines)
|
|
lines.erase(lines.begin(), lines.end() - static_cast<std::ptrdiff_t>(maxLines));
|
|
return lines;
|
|
}
|
|
|
|
void App::feedDaemonBusy(const std::string& chunk)
|
|
{
|
|
if (daemon_busy_reset_pending_.exchange(false)) daemon_busy_.reset();
|
|
daemon_busy_.feed(chunk);
|
|
node_rebuilding_ = daemon_busy_.rebuilding();
|
|
if (!daemon_busy_.busy()) busy_first_seen_ = {};
|
|
else if (busy_first_seen_ == std::chrono::steady_clock::time_point{}) busy_first_seen_ = std::chrono::steady_clock::now();
|
|
}
|
|
|
|
static std::string formatBusyDuration(double secs)
|
|
{
|
|
const long s = secs > 0 ? static_cast<long>(secs) : 0;
|
|
char buf[32];
|
|
if (s >= 3600) snprintf(buf, sizeof(buf), "%ld:%02ld:%02ld", s / 3600, (s / 60) % 60, s % 60);
|
|
else snprintf(buf, sizeof(buf), "%ld:%02ld", s / 60, s % 60);
|
|
return buf;
|
|
}
|
|
|
|
App::NodeBusyText App::nodeBusyText() const
|
|
{
|
|
NodeBusyText out;
|
|
// Once the node the tracker follows is gone, its last state means nothing.
|
|
const bool ours = busyTrackerIsLive();
|
|
const bool rebuilding = ours && daemon_busy_.rebuilding();
|
|
const bool replaying = ours && daemon_busy_.replaying() && !rebuilding;
|
|
const bool rpcBusy = rpc_busy_gate_->busy();
|
|
if (!rebuilding && !replaying && !rpcBusy) return out;
|
|
out.busy = true;
|
|
out.rebuilding = rebuilding;
|
|
|
|
// Running time: from the node's own (UTC) log timestamp when there is one, else since we first saw it.
|
|
double secs = rpc_busy_gate_->busySeconds();
|
|
if (rebuilding || replaying) {
|
|
const std::int64_t startUtc = rebuilding ? daemon_busy_.rebuildStartUtc() : daemon_busy_.replayStartUtc();
|
|
if (startUtc > 0) {
|
|
secs = static_cast<double>(static_cast<std::int64_t>(std::time(nullptr)) - startUtc);
|
|
} else if (busy_first_seen_ != std::chrono::steady_clock::time_point{}) {
|
|
secs = std::chrono::duration<double>(std::chrono::steady_clock::now() - busy_first_seen_).count();
|
|
}
|
|
}
|
|
out.elapsed = formatBusyDuration(secs);
|
|
|
|
char buf[512];
|
|
std::string progress;
|
|
if (rebuilding) {
|
|
out.title = TR("node_busy_rebuild_title");
|
|
snprintf(buf, sizeof(buf), TR("node_busy_rebuild_desc"), out.elapsed.c_str());
|
|
out.detail = buf;
|
|
// Phase 1 logs a line per 5000 blocks read; Phase 2 (extending the witnesses) logs nothing.
|
|
const long done = daemon_busy_.readDone(), total = daemon_busy_.readTotal();
|
|
const bool reading = total > 0 && done + 5000 < total;
|
|
if (reading) snprintf(buf, sizeof(buf), TR("node_busy_rebuild_reading"), done, total);
|
|
else snprintf(buf, sizeof(buf), "%s", TR("node_busy_rebuild_extending"));
|
|
out.detail += " ";
|
|
out.detail += buf;
|
|
if (reading) progress = buf;
|
|
} else if (replaying) {
|
|
out.title = TR("node_busy_replay_title");
|
|
snprintf(buf, sizeof(buf), TR("node_busy_replay_desc"), out.elapsed.c_str());
|
|
out.detail = buf;
|
|
} else {
|
|
out.title = TR("node_busy_rpc_title");
|
|
snprintf(buf, sizeof(buf), TR("node_busy_rpc_desc"), out.elapsed.c_str());
|
|
out.detail = buf;
|
|
}
|
|
snprintf(buf, sizeof(buf), TR("node_busy_running"), out.elapsed.c_str());
|
|
out.compact = buf;
|
|
if (!progress.empty()) out.compact += " · " + progress;
|
|
return out;
|
|
}
|
|
|
|
bool App::busyTrackerIsLive() const
|
|
{
|
|
return (daemon_controller_ && daemon_controller_->isRunning()) || ext_log_tied_;
|
|
}
|
|
|
|
long App::externalNodePid() const
|
|
{
|
|
daemon::EmbeddedDaemon::OwnershipRecord rec;
|
|
if (daemon::EmbeddedDaemon::liveOwnershipRecord(daemon::EmbeddedDaemon::ownershipRecordPath(), &rec))
|
|
return rec.pid;
|
|
#ifndef _WIN32
|
|
// POSIX dragonxd writes <datadir>/hushd.pid (removed on a clean exit): a live dragonxd under that pid uses
|
|
// this datadir. (Windows writes none; there only our own record ties a node to this log.)
|
|
std::ifstream in((std::filesystem::path(util::Platform::getDragonXDataDir()) / "hushd.pid").string());
|
|
long pid = 0;
|
|
if (in >> pid && pid > 0 && daemon::EmbeddedDaemon::processIsDaemon(pid)) return pid;
|
|
#endif
|
|
return 0;
|
|
}
|
|
|
|
void App::pumpExternalDaemonLog()
|
|
{
|
|
if (!supportsEmbeddedDaemon()) return;
|
|
const double now = ImGui::GetTime();
|
|
if (now < ext_log_next_at_) return;
|
|
ext_log_next_at_ = now + 2.0;
|
|
|
|
auto untie = [this] {
|
|
if (!ext_log_tied_) return;
|
|
ext_log_tied_ = false;
|
|
ext_log_pid_ = 0;
|
|
daemon_busy_.reset();
|
|
node_rebuilding_ = false;
|
|
busy_first_seen_ = {};
|
|
DEBUG_LOGF("[busy] stopped following the external node's debug.log\n");
|
|
};
|
|
auto readRange = [](const std::string& path, std::uintmax_t from, std::uintmax_t to) {
|
|
std::string s;
|
|
std::ifstream in(path, std::ios::binary);
|
|
if (!in || to <= from) return s;
|
|
in.seekg(static_cast<std::streamoff>(from));
|
|
s.resize(static_cast<size_t>(to - from));
|
|
in.read(&s[0], static_cast<std::streamsize>(s.size()));
|
|
s.resize(static_cast<size_t>(in.gcount()));
|
|
return s;
|
|
};
|
|
|
|
if (daemon_controller_ && daemon_controller_->isRunning()) { untie(); return; } // ours feeds it directly
|
|
const long pid = externalNodePid();
|
|
if (pid <= 0) { untie(); return; }
|
|
const std::string path = (std::filesystem::path(util::Platform::getDragonXDataDir()) / "debug.log").string();
|
|
std::error_code ec;
|
|
const std::uintmax_t size = std::filesystem::file_size(path, ec);
|
|
if (ec) { untie(); return; }
|
|
|
|
if (!ext_log_tied_ || pid != ext_log_pid_ || size < ext_log_offset_) {
|
|
// (Re)tie: this process's lines from the last 2 MB, into a fresh tracker. The banner may be older than
|
|
// the window on a long-running node; a rebuild still in progress logged its markers recently.
|
|
constexpr std::uintmax_t kWindow = 2u * 1024u * 1024u;
|
|
const std::uintmax_t from = size > kWindow ? size - kWindow : 0;
|
|
const std::string window = readRange(path, from, size);
|
|
const size_t at = daemon::externalLogFeedOffset(window, from > 0,
|
|
daemon::EmbeddedDaemon::processStartUtc(pid));
|
|
untie();
|
|
if (at == std::string::npos) return; // not that process's log (it may log to its console)
|
|
daemon_busy_.reset();
|
|
ext_log_tied_ = true;
|
|
ext_log_pid_ = pid;
|
|
ext_log_offset_ = from + window.size();
|
|
DEBUG_LOGF("[busy] following the debug.log of a node this session didn't launch (pid %ld)\n", pid);
|
|
feedDaemonBusy(window.substr(at));
|
|
return;
|
|
}
|
|
if (size > ext_log_offset_) {
|
|
const std::uintmax_t to = std::min<std::uintmax_t>(size, ext_log_offset_ + 1024u * 1024u);
|
|
const std::string chunk = readRange(path, ext_log_offset_, to);
|
|
ext_log_offset_ += chunk.size();
|
|
feedDaemonBusy(chunk);
|
|
}
|
|
}
|
|
|
|
void App::resetDaemonBusy()
|
|
{
|
|
// Any thread (a restart/rescan task starts the node): the UI thread applies it before its next feed.
|
|
daemon_busy_reset_pending_ = true;
|
|
node_rebuilding_ = false;
|
|
}
|
|
|
|
void App::noteDaemonRpcResult(bool answered, rpc::RpcFailureKind kind)
|
|
{
|
|
if (answered) {
|
|
rpc_busy_since_ = {};
|
|
} else if (kind == rpc::RpcFailureKind::ReplyTimeout || kind == rpc::RpcFailureKind::QueueFull) {
|
|
if (rpc_busy_since_ == std::chrono::steady_clock::time_point{})
|
|
rpc_busy_since_ = std::chrono::steady_clock::now();
|
|
}
|
|
}
|
|
|
|
bool App::rpcBusyWhileAlive() const
|
|
{
|
|
// Two clocks: the connect/core-refresh results, and the busy gate. The gate hears every lane (since S6 the
|
|
// first timeout often lands on another lane, after which the core refresh is only ever Deferred).
|
|
const bool resultsBusy = rpc_busy_since_ != std::chrono::steady_clock::time_point{} &&
|
|
std::chrono::steady_clock::now() - rpc_busy_since_ >= std::chrono::seconds(30);
|
|
const bool gateBusy = rpc_busy_gate_ && rpc_busy_gate_->busySeconds() >= 30.0;
|
|
if (!resultsBusy && !gateBusy) return false;
|
|
return (daemon_controller_ && daemon_controller_->isRunning()) || daemon::EmbeddedDaemon::isRpcPortInUse();
|
|
}
|
|
|
|
bool App::mayStopExternalNode() const
|
|
{
|
|
if (settings_ && settings_->getStopExternalDaemon()) return true;
|
|
return daemon::EmbeddedDaemon::liveOwnershipRecord(daemon::EmbeddedDaemon::ownershipRecordPath());
|
|
}
|
|
|
|
bool App::shouldConfirmDaemonStop() const
|
|
{
|
|
// Only relevant when this shutdown would actually STOP the daemon (ours, ours left running by an
|
|
// earlier session, or external with stop-on-exit) — a DisconnectOnly shutdown loses nothing.
|
|
const bool keep = settings_ && settings_->getKeepDaemonRunning();
|
|
bool stops = false;
|
|
if (!daemon_controller_) {
|
|
stops = !keep && mayStopExternalNode();
|
|
} else {
|
|
const auto decision = daemon_controller_->shutdownDecision(keep, mayStopExternalNode());
|
|
stops = decision.action == daemon::DaemonController::ShutdownAction::StopDaemon;
|
|
}
|
|
// No daemon version suppresses this: v1.3.0 does not checkpoint a witness rebuild, and nothing survives
|
|
// a kill.
|
|
return stops && nodeBusyForStop();
|
|
}
|
|
|
|
bool App::nodeBusyForStop() const
|
|
{
|
|
// The tracker reads OUR node's output; once that process is gone its last state means nothing. An
|
|
// external node's busyness shows only as RPC that has stopped answering.
|
|
daemon::DaemonBusyTracker ours;
|
|
if (busyTrackerIsLive()) ours = daemon_busy_;
|
|
return daemon::shouldConfirmStop(true, ours, rpcBusyWhileAlive());
|
|
}
|
|
|
|
bool App::refuseIfNodeBusy()
|
|
{
|
|
if (!nodeBusyForStop()) return false;
|
|
DEBUG_LOGF("[App] refused: the node is busy (%s) — stopping it now would make it start over\n",
|
|
daemon_busy_.rebuilding() ? "rebuilding witnesses" : "not answering RPC");
|
|
ui::Notifications::instance().warning(TR("appx_node_busy_try_later"), 10.0f);
|
|
return true;
|
|
}
|
|
|
|
void App::renderDaemonStopConfirm()
|
|
{
|
|
using namespace ui::material;
|
|
if (pending_shutdown_confirm_) {
|
|
ImGui::OpenPopup("##DaemonStopConfirm");
|
|
pending_shutdown_confirm_ = false;
|
|
daemon_stop_confirm_open_ = true;
|
|
}
|
|
if (!daemon_stop_confirm_open_) return;
|
|
|
|
ImVec2 center = ImGui::GetMainViewport()->GetCenter();
|
|
ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
|
bool proceed = false;
|
|
if (ImGui::BeginPopupModal("##DaemonStopConfirm", nullptr,
|
|
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
|
|
// Honest about what each choice does now that the wallet never kills the node: stopping waits for
|
|
// it to finish or abort the rebuild (Phase 1 aborts and is redone on the next start; Phase 2 can't
|
|
// be interrupted). Keeping it running loses nothing, so it is the recommended, focused default.
|
|
const NodeBusyText busy = nodeBusyText();
|
|
std::string elapsed = busy.elapsed;
|
|
if (!busy.busy && rpc_busy_since_ != std::chrono::steady_clock::time_point{})
|
|
elapsed = formatBusyDuration(std::chrono::duration<double>(
|
|
std::chrono::steady_clock::now() - rpc_busy_since_).count());
|
|
const bool rebuilding = busy.rebuilding;
|
|
if (Type().subtitle1()) ImGui::PushFont(Type().subtitle1());
|
|
ImGui::TextUnformatted(TR(rebuilding ? "stopconfirm_rebuild_title" : "stopconfirm_busy_title"));
|
|
if (Type().subtitle1()) ImGui::PopFont();
|
|
ImGui::Spacing();
|
|
char body[1024];
|
|
snprintf(body, sizeof(body), TR(rebuilding ? "stopconfirm_rebuild_body" : "stopconfirm_busy_body"),
|
|
elapsed.empty() ? "0:00" : elapsed.c_str());
|
|
ImGui::PushTextWrapPos(ImGui::GetFontSize() * 26.0f);
|
|
ImGui::TextUnformatted(body);
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::Spacing();
|
|
ImGui::Spacing();
|
|
|
|
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(WithAlpha(Primary(), 210)));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(Primary()));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(WithAlpha(Primary(), 160)));
|
|
const bool keep = TactileButton(TR("appx_keep_node_running_and_quit"), ImVec2(0, 0));
|
|
ImGui::PopStyleColor(3);
|
|
ImGui::SetItemDefaultFocus();
|
|
if (keep) {
|
|
shutdown_keep_daemon_override_ = true;
|
|
shutdown_confirmed_ = true;
|
|
daemon_stop_confirm_open_ = false;
|
|
proceed = true;
|
|
ImGui::CloseCurrentPopup();
|
|
}
|
|
ImGui::SameLine();
|
|
if (TactileButton(TR("stopconfirm_stop_wait"), ImVec2(0, 0))) {
|
|
shutdown_confirmed_ = true;
|
|
daemon_stop_confirm_open_ = false;
|
|
proceed = true;
|
|
ImGui::CloseCurrentPopup();
|
|
}
|
|
ImGui::SameLine();
|
|
if (TactileButton(TR("appx_cancel"), ImVec2(0, 0))) {
|
|
daemon_stop_confirm_open_ = false; // abort the quit; stay open
|
|
ImGui::CloseCurrentPopup();
|
|
}
|
|
ImGui::EndPopup();
|
|
} else {
|
|
daemon_stop_confirm_open_ = false; // dismissed via Esc / click-away = Cancel
|
|
}
|
|
|
|
// Re-enter shutdown outside the popup scope now that the user has chosen (shutdown_confirmed_ set).
|
|
if (proceed) beginShutdown();
|
|
}
|
|
|
|
void App::renderShutdownScreen()
|
|
{
|
|
using namespace ui::material;
|
|
const float dp = ui::Layout::dpiScale();
|
|
auto shutElem = [](const char* key, float fb) {
|
|
float v = ui::schema::UI().drawElement("components.shutdown", key).size;
|
|
return v >= 0 ? v : fb;
|
|
};
|
|
// Elapsed since beginShutdown, from the wall clock: beginShutdown runs from SDL event handling, so an
|
|
// accumulated delta would count the idle gap before the close request as shutdown time.
|
|
shutdown_timer_ = std::chrono::duration<float>(std::chrono::steady_clock::now() - shutdown_start_time_).count();
|
|
|
|
const std::string curShut = shutdown_status_.get(); // one consistent snapshot per frame (M-05)
|
|
// After a few seconds, offer to close the wallet and leave the node running: stopping can take as long
|
|
// as the node needs (a witness rebuild can hold it for an hour), and it is never killed unasked.
|
|
const bool allowChoices = shutdown_timer_ >= 3.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 the choices are offered, so the screen stays click-through for a quick exit.
|
|
if (!allowChoices)
|
|
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) * dp * 2.0f + 12.0f * dp;
|
|
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) * dp;
|
|
float thick = shutElem("spinner-thickness", 3.0f) * dp;
|
|
// Screen-space centre for draw list
|
|
ImVec2 sc(wp.x + cx, wp.y + ImGui::GetCursorPosY() + r + 2.0f * dp);
|
|
|
|
// 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 * dp));
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
|
|
// -------------------------------------------------------------------
|
|
// 3. Phase status (what the shutdown thread is doing)
|
|
// -------------------------------------------------------------------
|
|
if (!curShut.empty()) {
|
|
ImVec2 ts = ImGui::CalcTextSize(curShut.c_str());
|
|
ImGui::SetCursorPosX(cx - ts.x * 0.5f);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.75f, 0.75f, 0.75f, 1.0f));
|
|
ImGui::TextUnformatted(curShut.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), TR("appx_n_seconds"), secs);
|
|
else
|
|
snprintf(elapsed, sizeof(elapsed), TR("appx_n_min_n_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. "Leave running" (always, after a few seconds) and "Kill" (only once the node looks stuck)
|
|
// -------------------------------------------------------------------
|
|
if (allowChoices) {
|
|
ImGui::Spacing();
|
|
ImGui::Spacing();
|
|
const char* leaveLabel = TR("shutdown_leave_running");
|
|
ImVec2 leaveSize(ImGui::CalcTextSize(leaveLabel).x + 32.0f * dp, 0);
|
|
ImGui::SetCursorPosX(cx - leaveSize.x * 0.5f);
|
|
if (ui::material::TactileButton(leaveLabel, leaveSize)) {
|
|
DEBUG_LOGF("Shutdown: user chose to leave the node running after %.0fs\n", shutdown_timer_);
|
|
chooseStopAction(daemon::StopUserChoice::Detach);
|
|
// No stop session (yet): nothing to wait for, so close now. A session that starts later sees
|
|
// the choice and returns at once.
|
|
if (!stop_session_active_) shutdown_complete_ = true;
|
|
}
|
|
if (ImGui::IsItemHovered()) {
|
|
ui::material::Tooltip("%s", TR("shutdown_leave_running_tip"));
|
|
}
|
|
|
|
if (stop_kill_offered_) {
|
|
ImGui::Spacing();
|
|
char idleMsg[160];
|
|
snprintf(idleMsg, sizeof(idleMsg), TR("shutdown_stop_idle"), stop_idle_minutes_.load());
|
|
ImVec2 ms = ImGui::CalcTextSize(idleMsg);
|
|
ImGui::SetCursorPosX(cx - ms.x * 0.5f);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
|
|
ImGui::TextUnformatted(idleMsg);
|
|
ImGui::PopStyleColor();
|
|
ImGui::Spacing();
|
|
const char* killLabel = TR("shutdown_kill_node");
|
|
ImVec2 killSize(ImGui::CalcTextSize(killLabel).x + 32.0f * dp, 0);
|
|
ImGui::SetCursorPosX(cx - killSize.x * 0.5f);
|
|
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(killLabel, killSize)) {
|
|
stop_kill_confirm_ = true;
|
|
}
|
|
ImGui::PopStyleColor(3);
|
|
if (ImGui::IsItemHovered()) {
|
|
ui::material::Tooltip("%s", TR("shutdown_kill_node_tip"));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Kill confirmation
|
|
if (stop_kill_confirm_) {
|
|
ImGui::OpenPopup("##KillNodeConfirm");
|
|
stop_kill_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("##KillNodeConfirm", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
|
|
ImGui::PushFont(Type().subtitle1());
|
|
ImGui::TextUnformatted(TR("shutdown_kill_confirm_title"));
|
|
ImGui::PopFont();
|
|
ImGui::Spacing();
|
|
ImGui::TextUnformatted(TR("shutdown_kill_confirm_msg"));
|
|
ImGui::Spacing();
|
|
ImGui::Spacing();
|
|
|
|
float btnW = 120.0f * dp;
|
|
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("shutdown_kill_confirm_yes"), ImVec2(btnW, 0))) {
|
|
DEBUG_LOGF("Shutdown: user confirmed Kill after %.0fs\n", shutdown_timer_);
|
|
chooseStopAction(daemon::StopUserChoice::Kill);
|
|
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 * dp));
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
|
|
// -------------------------------------------------------------------
|
|
// 6. Daemon output panel — terminal-style box
|
|
// -------------------------------------------------------------------
|
|
if (daemon_controller_) {
|
|
auto lines = daemon_controller_->recentLines(8);
|
|
// External daemon (attached, not spawned) has no captured stdout — tail its debug.log directly
|
|
// so the user can still watch the node flush the block index and exit.
|
|
if (lines.empty())
|
|
lines = tailDaemonDebugLog(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(TR("appx_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 * dp);
|
|
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;
|
|
|
|
// The wallet-recovery dialog owns the screen while a salvage is pending — suppress the loading
|
|
// spinner underneath it so "still loading" and "make a decision" are never the same screen.
|
|
if (show_wallet_recovered_dialog_) return;
|
|
RequestProgressFrame(); // the spinner and status text keep moving while the node loads
|
|
|
|
auto loadElem = [](const char* key, float fb) {
|
|
float v = ui::schema::UI().drawElement("screens.loading", key).size;
|
|
return v >= 0 ? v : fb;
|
|
};
|
|
|
|
loading_timer_ += logic_dt_;
|
|
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
ImVec2 wp = ImGui::GetWindowPos();
|
|
ImVec2 ws = ImGui::GetWindowSize();
|
|
|
|
// Hand-drawn geometry here is in logical px; the fonts drawn between the elements are DPI-baked,
|
|
// so every spinner/bar/gap constant must be multiplied by dpiScale or it renders native-tiny and
|
|
// the spacing/centering drifts at HiDPI / font_scale > 1. (Font metrics are already scaled.)
|
|
const float dpi = ui::Layout::dpiScale();
|
|
|
|
// Layout constants
|
|
float lineH = ImGui::GetTextLineHeightWithSpacing();
|
|
float spinnerR = loadElem("spinner-radius", 18.0f) * dpi;
|
|
float gap = loadElem("vertical-gap", 8.0f) * dpi;
|
|
float barH = loadElem("progress-bar", 6.0f) * dpi;
|
|
float barW = loadElem("progress-width", 260.0f) * dpi;
|
|
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) * dpi;
|
|
ImVec2 sc(wp.x + cx, curY + r + 2.0f * dpi);
|
|
|
|
// 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 * dpi;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Post-repair rescan — a calm, recovery-aware screen (not the generic "daemon stuck / RPC timeout /
|
|
// restart daemon" text). The daemon was intentionally restarted with a full rescan, which
|
|
// legitimately takes minutes and won't answer RPC yet; reassure + show elapsed and the growing size.
|
|
// -------------------------------------------------------------------
|
|
// Only while the daemon is actually alive-and-rescanning. If it EXITED (crashed for a reason distinct
|
|
// from the wallet file), fall through to the normal error/stall UI — tryConnect() clears the flag, but
|
|
// gate here too so a lingering frame never shows "everything's fine" over a dead daemon.
|
|
if (post_recovery_rescan_ &&
|
|
!(daemon_controller_ && daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error)) {
|
|
if (post_recovery_rescan_since_ <= 0.0) post_recovery_rescan_since_ = ImGui::GetTime();
|
|
ImFont* titleF = Type().subtitle1(); if (!titleF) titleF = ImGui::GetFont();
|
|
ImFont* capF = Type().caption(); if (!capF) capF = ImGui::GetFont();
|
|
|
|
auto centeredLine = [&](ImFont* f, ImU32 col, const char* s, float wrapW) {
|
|
ImVec2 ts = f->CalcTextSizeA(f->LegacySize, FLT_MAX, wrapW, s);
|
|
float x = (wrapW > 0.0f) ? (wp.x + cx - wrapW * 0.5f) : (wp.x + cx - ts.x * 0.5f);
|
|
dl->AddText(f, f->LegacySize, ImVec2(x, curY), col, s, nullptr, wrapW);
|
|
curY += ts.y + gap;
|
|
};
|
|
|
|
centeredLine(titleF, IM_COL32(230, 210, 90, 235), TR("wallet_recovery_rescan_title"), 0.0f);
|
|
float wrapW = ws.x * 0.8f; if (wrapW > 620.0f * dpi) wrapW = 620.0f * dpi;
|
|
centeredLine(capF, IM_COL32(200, 200, 200, 220), TR("wallet_recovery_rescan_body"), wrapW);
|
|
|
|
// Elapsed + the growing wallet size — concrete "it's working" feedback while RPC is silent. Recompute
|
|
// at most once a second (the display granularity) to avoid a stat()+format on every frame.
|
|
int secs = (int)(ImGui::GetTime() - post_recovery_rescan_since_); if (secs < 0) secs = 0;
|
|
static int s_lastSec = -1;
|
|
static std::string s_elapsed, s_size;
|
|
if (secs != s_lastSec) {
|
|
s_lastSec = secs;
|
|
char clock[16]; snprintf(clock, sizeof(clock), "%d:%02d", secs / 60, secs % 60);
|
|
char ebuf[96]; snprintf(ebuf, sizeof(ebuf), TR("wallet_recovery_rescan_elapsed"), clock);
|
|
s_elapsed = ebuf;
|
|
const std::string walletPath = util::Platform::getDragonXDataDir() + "/" +
|
|
((settings_ && !settings_->getActiveWalletFile().empty())
|
|
? settings_->getActiveWalletFile() : std::string("wallet.dat"));
|
|
uint64_t wsz = util::Platform::getFileSize(walletPath);
|
|
if (wsz > 0) { char sbuf[96]; snprintf(sbuf, sizeof(sbuf), TR("wallet_recovery_rescan_size"),
|
|
util::Platform::formatFileSize(wsz).c_str()); s_size = sbuf; }
|
|
else s_size.clear();
|
|
}
|
|
curY += gap * 0.5f;
|
|
centeredLine(capF, IM_COL32(150, 150, 150, 220), s_elapsed.c_str(), 0.0f);
|
|
if (!s_size.empty()) centeredLine(capF, IM_COL32(130, 130, 130, 210), s_size.c_str(), 0.0f);
|
|
|
|
// Backstop for a genuinely slow (but still running) rescan: after several minutes add a gentle
|
|
// "it's safe to leave running / watch the Console" line — never the scary "restart the node".
|
|
if (secs > 600) {
|
|
curY += gap * 0.5f;
|
|
centeredLine(capF, IM_COL32(150, 150, 150, 200), TR("wallet_recovery_rescan_slow"), wrapW);
|
|
}
|
|
return; // skip the generic status / stall / error / daemon-log sections
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 2. Connection / daemon status text
|
|
// -------------------------------------------------------------------
|
|
{
|
|
const char* statusText = connection_status_.c_str();
|
|
ImFont* font = Type().subtitle1();
|
|
if (!font) font = ImGui::GetFont();
|
|
// Short statuses (the common case: "Connected", "Starting daemon") stay centered; long ones
|
|
// (a full dir_error path, a libcurl connect error) wrap to a clamped box instead of running
|
|
// off both edges of the overlay — the same clamp the daemon-error blocks below use.
|
|
float wrapW = ws.x * 0.8f; if (wrapW > 640.0f * dpi) wrapW = 640.0f * dpi;
|
|
ImVec2 full = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0.0f, statusText);
|
|
if (full.x <= wrapW) {
|
|
dl->AddText(font, font->LegacySize,
|
|
ImVec2(wp.x + cx - full.x * 0.5f, curY),
|
|
IM_COL32(220, 220, 220, 255), statusText);
|
|
curY += full.y + gap;
|
|
} else {
|
|
ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, wrapW, statusText);
|
|
dl->AddText(font, font->LegacySize,
|
|
ImVec2(wp.x + cx - wrapW * 0.5f, curY),
|
|
IM_COL32(220, 220, 220, 255), statusText, nullptr, wrapW);
|
|
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) * dpi;
|
|
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), TR("appx_rebuilding_witness_cache_blocks_left"),
|
|
progress * 100.0f, state_.sync.witness_remaining);
|
|
else if (state_.sync.witness_phase == 2 && progress > 0.01f)
|
|
snprintf(wbuf, sizeof(wbuf), TR("appx_rebuilding_witness_cache_pct"), progress * 100.0f);
|
|
else if (progress > 0.01f)
|
|
snprintf(wbuf, sizeof(wbuf), TR("appx_setting_initial_sapling_witnesses"), progress * 100.0f);
|
|
else
|
|
snprintf(wbuf, sizeof(wbuf), "%s", TR("appx_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) * dpi;
|
|
|
|
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), TR("appx_syncing_pct_block_n_of_n"),
|
|
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), TR("appx_last_block_n"), 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_
|
|
? TR("appx_encrypting_wallet")
|
|
: TR("appx_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 * dpi;
|
|
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 &&
|
|
!wallet_auto_recovered_) { // a salvage is NOT a crash — it has its own recovery dialog
|
|
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 = TR("appx_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) — full diagnostic info for a genuine (non-recovery) crash.
|
|
const std::string& errDetail = daemon_controller_->lastError();
|
|
if (!errDetail.empty()) {
|
|
float wrapW = ws.x * 0.8f;
|
|
if (wrapW > 700.0f * dpi) wrapW = 700.0f * dpi;
|
|
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 = TR("appx_use_settings_restart_daemon_hint");
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 3d. The "Taking longer than expected" stall notice was intentionally removed — it added
|
|
// clutter to the startup screen. The live daemon-output panel below is the real signal that
|
|
// the node is making progress. (connect_stall_since_ is still maintained in app_network.cpp
|
|
// for connection bookkeeping; it just no longer drives any on-screen text.)
|
|
|
|
// -------------------------------------------------------------------
|
|
// 4. Daemon output snippet (last few lines, if embedded)
|
|
// -------------------------------------------------------------------
|
|
if (daemon_controller_ && !wallet_auto_recovered_) { // recovery moves the log behind the dialog's disclosure
|
|
auto lines = daemon_controller_->recentLines(8);
|
|
if (!lines.empty()) {
|
|
curY += gap;
|
|
|
|
float panelW = ws.x * 0.85f;
|
|
if (panelW > 900.0f * dpi) panelW = 900.0f * dpi;
|
|
float panelX = wp.x + cx - panelW * 0.5f;
|
|
float panelPad = 8.0f * dpi;
|
|
|
|
ImFont* capFont = Type().caption();
|
|
if (!capFont) capFont = ImGui::GetFont();
|
|
float panelLineH = capFont->LegacySize + 4.0f * dpi;
|
|
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()
|
|
{
|
|
// Lite: let an in-flight wallet op (e.g. Encrypt's save) finish. Bounded: the window is already hidden and
|
|
// main.cpp has a 15 s watchdog. The backend writes tmp + rename, so being cut off never corrupts the wallet.
|
|
if (lite_wallet_) lite_wallet_->waitWalletOpsIdle(std::chrono::milliseconds(2500));
|
|
|
|
// Full node: persist whatever a refresh changed since beginShutdown() (everything, on the direct-exit path),
|
|
// then drain + join the history writer. Bounded like the settings flush: on a wedged disk the in-flight write
|
|
// is left running (one autocommit INSERT in WAL mode: a cut-off write keeps the previous snapshot) and _Exit
|
|
// ends it. Lite: the store returns at once and the writer never started.
|
|
storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now);
|
|
if (history_writer_.flush(std::chrono::seconds(3))) history_writer_.stop();
|
|
else DEBUG_LOGF("[History] snapshot write still running at exit - left to finish (WAL keeps the old one)\n");
|
|
|
|
// Ensure the mining-control thread is stopped + joined (idempotent; beginShutdown already did it on the
|
|
// normal quit path, but shutdown() can also run without it). Must precede xmrig_manager_ teardown.
|
|
stopMiningControlThread();
|
|
abortRpcBusyCanary();
|
|
if (canary_thread_.joinable()) canary_thread_.join();
|
|
|
|
// Wipe any copied secret from the OS clipboard before we exit — the 45s auto-clear timer
|
|
// never fires if the user quits sooner, which would otherwise leave a key/seed resident.
|
|
// (ImGui context is still alive here; App::shutdown() runs before ImGui::DestroyContext().)
|
|
clearSecretClipboardIfArmed();
|
|
|
|
// 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");
|
|
http_cancel_.store(true, std::memory_order_relaxed); // queued / in-flight market GETs end at once
|
|
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 (http_worker_) {
|
|
http_worker_->stop();
|
|
}
|
|
if (settings_) {
|
|
settings_->save();
|
|
}
|
|
daemon_mem_sampler_.stop();
|
|
if (daemon_controller_) {
|
|
stopEmbeddedDaemon(StopMode::Fallback); // on the UI thread: bounded, never killed
|
|
}
|
|
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();
|
|
}
|
|
if (http_worker_) {
|
|
http_worker_->stop(); // http_cancel_ was set in beginShutdown(): an in-flight GET aborts within ~1 s
|
|
}
|
|
daemon_mem_sampler_.stop(); // joins a probe in flight (tens of ms at most)
|
|
// 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 {
|
|
// Gate purely on the (now per-wallet) vault presence — a vault exists iff a PIN was set for THIS
|
|
// wallet. The old extra `getPinEnabled()` gate was a GLOBAL flag, so disabling PIN on one wallet
|
|
// wrongly suppressed another wallet's PIN quick-unlock after a switch.
|
|
return vault_ && vault_->hasVault();
|
|
}
|
|
|
|
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::hasPendingBackgroundResults() const {
|
|
return (worker_ && worker_->hasPendingResults())
|
|
|| (fast_worker_ && fast_worker_->hasPendingResults())
|
|
|| (http_worker_ && http_worker_->hasPendingResults()) // g3: market-data GETs
|
|
|| (lite_wallet_ && lite_wallet_->hasWalletOpCompletions()) // g1: lite wallet-op lane
|
|
|| (startup_files_ && startup_files_->hasCompletion()); // g6: startup node files
|
|
}
|
|
|
|
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;
|
|
if (!rpc_ || !rpc_->isConnected()) return {}; // Lite, or no node: there's no full-node history scan
|
|
bool running = network_refresh_.jobInProgress(Job::Transactions);
|
|
bool canRefresh = state_.connected && !state_.warming_up && !state_.isLocked();
|
|
const auto work = historyScanWork();
|
|
// The first load spans several cycles, and the dirty flag now clears after each: keep the banner up
|
|
// between them while addresses are still to scan.
|
|
const bool initialLoad = !initial_history_scan_complete_ && work.total > 0;
|
|
if (!running && !(canRefresh && (transactions_dirty_ || (initialLoad && work.pending > 0)))) return {};
|
|
// The history scans wait while the node catches up.
|
|
if (!running && state_.sync.syncing) return TR("tx_loading_waits_for_sync");
|
|
|
|
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 shielded receive scans stream older transactions in over several
|
|
// 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 is the
|
|
// share of z-addresses this pass has covered. Goes quiet for good once the first pass completes;
|
|
// routine re-scans afterwards don't re-trigger it.
|
|
if (initialLoad) {
|
|
const std::size_t total = work.total;
|
|
const std::size_t done = total - std::min(work.pending, total);
|
|
int pct = static_cast<int>((done * 100) / total);
|
|
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: new receives 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(TR("appx_copied_clipboard_autoclears"), 4.0f);
|
|
}
|
|
|
|
void App::clearSecretClipboardIfArmed()
|
|
{
|
|
if (clipboard_secret_hash_ == 0) 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::pumpSecretClipboardClear()
|
|
{
|
|
if (clipboard_clear_deadline_ <= 0.0) return;
|
|
if (ImGui::GetTime() < clipboard_clear_deadline_) return;
|
|
clearSecretClipboardIfArmed();
|
|
}
|
|
|
|
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. While the address scan can't run (the
|
|
// background, a catch-up, the connect gate, a retry hold), finish once the operations are done:
|
|
// addresses_dirty_ stays set, so the scan still runs when it can.
|
|
if (addressScanCanRunNow() && (addresses_dirty_ || network_refresh_.jobInProgress(Job::Addresses))) return;
|
|
send_progress_active_ = false;
|
|
}
|
|
std::string App::buildDiagnosticsReport()
|
|
{
|
|
std::ostringstream os;
|
|
os << "=== ObsidianDragon diagnostics ===\n";
|
|
os << "version: " << DRAGONX_VERSION << "\n";
|
|
#if DRAGONX_LITE_BUILD
|
|
os << "variant: Lite\n";
|
|
#else
|
|
os << "variant: Full-node\n";
|
|
#endif
|
|
#if defined(_WIN32)
|
|
os << "platform: windows\n";
|
|
#elif defined(__APPLE__)
|
|
os << "platform: macos\n";
|
|
#else
|
|
os << "platform: linux\n";
|
|
#endif
|
|
os << "connected: " << (state_.connected ? "yes" : "no") << "\n";
|
|
os << "status: " << connection_status_ << "\n";
|
|
|
|
const std::string activeWallet = settings_ ? settings_->getActiveWalletFile() : std::string("(none)");
|
|
os << "active wallet: " << activeWallet << "\n";
|
|
{
|
|
std::error_code ec;
|
|
const std::string wp = util::Platform::getDragonXDataDir() + "/" + activeWallet;
|
|
const bool present = std::filesystem::exists(wp, ec);
|
|
os << " path: " << wp << (present ? " [present" : " [MISSING");
|
|
if (present) { auto sz = std::filesystem::file_size(wp, ec); if (!ec) os << ", " << sz << " bytes"; }
|
|
os << "]\n";
|
|
}
|
|
os << "encryption: "
|
|
<< (state_.encryption_state_known
|
|
? (state_.encrypted ? (state_.locked ? "encrypted, locked" : "encrypted, unlocked") : "unencrypted")
|
|
: "unknown")
|
|
<< "\n";
|
|
os << "sync: block " << state_.sync.blocks << " / " << state_.sync.headers
|
|
<< (state_.sync.syncing ? " (syncing)" : "")
|
|
<< (state_.warming_up ? " (warming up)" : "") << "\n";
|
|
|
|
#if !DRAGONX_LITE_BUILD
|
|
os << "daemon status: " << getDaemonStatus() << "\n";
|
|
if (daemon_controller_) {
|
|
os << "daemon running: " << (daemon_controller_->isRunning() ? "yes" : "no")
|
|
<< ", crashes: " << daemon_controller_->crashCount() << "\n";
|
|
const std::string derr = daemon_controller_->lastError();
|
|
if (!derr.empty()) os << "daemon lastError: " << derr << "\n";
|
|
}
|
|
#endif
|
|
|
|
const std::string cfg = util::Platform::getObsidianDragonDir();
|
|
os << "log folder: " << cfg << "\n";
|
|
os << " " << cfg << "/dragonx-debug.log\n";
|
|
os << " " << cfg << "/dragonx-crash.log\n";
|
|
return os.str();
|
|
}
|
|
|
|
void App::restartDaemon()
|
|
{
|
|
if (!supportsFullNodeLifecycleActions()) {
|
|
ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
|
|
return;
|
|
}
|
|
|
|
auto decision = daemon::DaemonController::evaluateLifecycleOperation(
|
|
daemon::DaemonController::LifecycleOperation::ManualRestart,
|
|
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning(), daemon_restarting_.load());
|
|
if (!decision.allowed) return;
|
|
if (refuseIfNodeBusy()) 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()) {
|
|
abortMaintenanceCalls(); // a long call holds the curl lock disconnect() needs
|
|
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_);
|
|
const auto result = daemon_controller_->executeLifecycleOperation(decision, runtime, context);
|
|
// Re-arm the connect loop either way: it reconnects to the new node, or to the old one still running.
|
|
daemon_restarting_ = false;
|
|
if (result.stopFailed) {
|
|
DEBUG_LOGF("[App] Daemon restart cancelled: the node did not exit; not starting a second one\n");
|
|
pending_stop_failed_.fetch_or(kStopFailedNotify);
|
|
return;
|
|
}
|
|
DEBUG_LOGF("[App] Daemon restart complete — waiting for RPC...\n");
|
|
});
|
|
}
|
|
|
|
// One-click recovery for an unreadable block database: arm the one-shot -reindex flag, then un-gate the
|
|
// connect loop (which detected the abort and stopped restarting). Its next attempt calls
|
|
// startEmbeddedDaemon(), which consumes the flag → the node rebuilds its block index + chainstate from
|
|
// the raw blocks. The daemon is not running here (it aborted), so no explicit stop/restart is needed.
|
|
void App::reindexBlockDatabase()
|
|
{
|
|
if (!supportsFullNodeLifecycleActions()) {
|
|
ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
|
|
return;
|
|
}
|
|
if (!daemon_controller_) return;
|
|
daemon_controller_->setReindexOnNextStart(true);
|
|
user_initiated_rescan_ = true; // reindex implies a rescan → its completion should toast
|
|
daemon_controller_->resetCrashCount(); // the abort no longer counts against the restart budget
|
|
show_block_db_reindex_confirm_ = false;
|
|
block_db_reindex_available_ = false; // un-gate → the connect loop restarts the node with -reindex
|
|
connection_status_ = TR("sb_starting_daemon");
|
|
ui::Notifications::instance().info(TR("block_db_reindex_started"), 12.0f);
|
|
DEBUG_LOGF("[App] Block-database reindex requested — restarting node with -reindex\n");
|
|
}
|
|
|
|
} // namespace dragonx
|