// 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 // std::getenv for the lite kill-switch env var #include // 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/about_dialog.h" #include "ui/windows/faq_dialog.h" #include "embedded/IconsMaterialDesign.h" #include "ui/windows/key_export_dialog.h" #include "ui/imgui_secret_scrub.h" // scrubImGuiInputTextState — zero ImGui's retained secret buffers #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 (INCBIN) — fallback when file not on disk #include "embedded/embedded_text.h" #include "imgui.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 #include #include #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) sodium_memzero(import_clip_preview_, sizeof(import_clip_preview_)); // clipboard preview (SECRET) ui::scrubImGuiInputTextState(); // zero ImGui's retained copies of whatever secret was last typed } // Scrub + close any secret that is currently ON SCREEN (revealed seed phrase, migration seed, exported // key) when the wallet locks — locking shouldn't leave a phrase/key visible or resident. Called on every // lock transition (manual, auto, lite). Separate from wipeSecrets(), which is the exit-path scrub. void App::wipeDisplayedSecrets() { if (!seed_backup_phrase_.empty()) { sodium_memzero(&seed_backup_phrase_[0], seed_backup_phrase_.size()); seed_backup_phrase_.clear(); } if (!seed_migration_seed_.empty()) { sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size()); seed_migration_seed_.clear(); } seed_backup_fetch_started_ = false; show_seed_backup_ = false; sodium_memzero(export_result_, sizeof(export_result_)); ui::KeyExportDialog::hide(); // zeros its static s_key via secureWipeLiteSecret console_tab_.wipeSecretResult(); // drop any masked key-dump result held in the console ui::scrubImGuiInputTextState(); // zero ImGui's retained copy of the last-typed passphrase/PIN } // 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 job) { { std::lock_guard 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 j; { std::unique_lock 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::signalMiningControlStop() { { std::lock_guard lk(mining_ctl_mutex_); mining_ctl_stop_ = true; } mining_ctl_cv_.notify_all(); } void App::stopMiningControlThread() { signalMiningControlStop(); if (mining_ctl_thread_.joinable()) mining_ctl_thread_.join(); } // Blocking miner teardown, run OFF the UI thread at quit. Joins the control thread first (waits out any in-flight // start/stop job) so this is the sole driver of xmrig_manager_, then stops the miner. void App::finishMinerShutdown() { if (mining_ctl_thread_.joinable()) mining_ctl_thread_.join(); if (xmrig_manager_ && xmrig_manager_->isRunning()) xmrig_manager_->stop(3000); } 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(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) const bool autoMode = settings_->getPoolSelectMode() == config::Settings::PoolSelectMode::AutoBalance; // Manual mode (or POOL mode off): honour a manual Refresh of the DISPLAY stats only — never // auto-switch the pool or restart xmrig. Display-only, so no benchmark gate here. if (!autoMode || !settings_->getPoolMode()) { if (balance_refresh_pending_ && settings_->getPoolMode() && !pool_stats_service_.busy()) { balance_refresh_pending_ = false; pool_stats_service_.refresh(util::knownPools()); } return; // do NOT fall into applyPoolAutoBalance } 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 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 { // Capture the absolute end NOW (lifecycle thread, after stop / before start) so the UI resumes // parsing only this run's output, not the retained tail of the previous run. app_.daemon_output_reset_to_.store(app_.daemon_controller_ ? app_.daemon_controller_->outputEnd() : 0); app_.daemon_output_reset_pending_.store(true); 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 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(g_ui_toml_size)) { DEBUG_LOGF("[INFO] ui.toml on disk (%ju bytes) differs from embedded (%zu bytes) — updating\n", (uintmax_t)diskSize, (size_t)g_ui_toml_size); std::ofstream ofs(schemaPath, std::ios::binary | std::ios::trunc); if (ofs.is_open()) { ofs.write(reinterpret_cast(g_ui_toml_data), g_ui_toml_size); ofs.close(); } } } 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) { std::string embedded(reinterpret_cast(g_ui_toml_data), g_ui_toml_size); ui::schema::UISchema::instance().loadFromString(embedded, "embedded"); } 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(); 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(); } // "Use Tor" never did anything. Clear a stored opt-in once, synchronously and before any Settings // page state is loaded (so nothing writes it back), and warn that user below, once the saved // language is loaded. const bool legacyTorCleared = settings_->consumeLegacyUseTor(); if (legacyTorCleared) { DEBUG_LOGF("[INFO] Cleared the unimplemented use_tor setting\n"); settings_->save(); // no writer attached yet: synchronous } // 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= 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(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); } if (legacyTorCleared) ui::Notifications::instance().warning(TR("tor_never_applied_notice"), 20.0f); // Initialize RPC client rpc_ = std::make_unique(); rpc_->setBusyGate(rpc_busy_gate_); // Initialize background RPC worker thread worker_ = std::make_unique(); 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(); 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() { // Free textures retired during the previous frame (its draw data has been rendered by now). for (ImTextureID t : pending_texture_destroy_) util::DestroyTexture(t); pending_texture_destroy_.clear(); 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(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(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(); // Real input arrives through App::noteUserInput() (main.cpp event ingest). What stays here covers input // that produces no new SDL event this frame: a held mouse button (a drag or scrollbar grab without // motion). NOT in the background: a minimized window runs update() with the last frame's IO, and a stale // mouse delta once kept auto-lock from ever firing. Deliberately NOT IsAnyItemActive(): a focused text // field stays active indefinitely (C21). if (!window_background_ && (io.MouseDelta.x != 0 || io.MouseDelta.y != 0 || io.MouseClicked[0] || io.MouseClicked[1] || io.InputQueueCharacters.Size > 0 || ImGui::IsAnyMouseDown())) { idle_.noteActivity(); } // 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_autoopen_done_ || nowSecs - lite_open_last_attempt_ > retryInterval) && lite_wallet_->walletExists()) { 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_) { // A transport-style failure may mean the server accepted the tx but the reply was // lost: tag it so the Send tab says "check History" instead of inviting a resend. const std::string liteErr = (!broadcast.ok && rpc::liteSendErrorMayBeInFlight(broadcast.error)) ? rpc::markSendMaybeInFlight(broadcast.error) : broadcast.error; lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : liteErr); 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..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(); reconcileUnlockExpiry(); // C90: follow the daemon when its absolute unlock window ran out } // 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(); // Thread benchmark: ungated (xmrig->pool is daemon-independent) so it advances while minimized/tab hidden. ui::TickMiningBenchmark(this, state_.pool_mining.hashrate_10s); // P8: Dedup rebuildAddressList — only rebuild once per frame if (address_list_dirty_) { address_list_dirty_ = false; state_.rebuildAddressList(); } // Move legacy (address-hash) portfolio scopes to the stable wallet id once this wallet's addresses are in. reattachLegacyPortfolioScopes(); 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()) * 100.0f; } catch (...) {} } else if (info.contains("rescan_progress") && info["rescan_progress"].is_number()) { progress = info["rescan_progress"].get() * 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.running_since = 0; state_.pool_mining.last_error.clear(); 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(); const std::string mmsg = err.empty() ? std::string(TR("appx_miner_stopped_unexpectedly")) : (std::string(TR("appx_miner_stopped_prefix")) + err); setMiningError(mmsg); // persist: a miner that crashed/was killed otherwise just "did nothing" ui::Notifications::instance().error(mmsg); } // 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(memMB * 1024.0 * 1024.0); ps.threads_active = xs.threads_active; ps.pool_hashrate = xs.pool_hashrate; ps.log_lines = xmrig_manager_->getRecentLines(30); if (ps.running_since == 0) ps.running_since = std::time(nullptr); // While not yet connected, surface the newest error-looking log line so "Connecting" can flip // to a real "can't reach pool" after a timeout instead of sitting on red forever. ps.last_error.clear(); if (!ps.connected) { for (auto it = ps.log_lines.rbegin(); it != ps.log_lines.rend(); ++it) { std::string l = *it; std::transform(l.begin(), l.end(), l.begin(), [](unsigned char c){ return (char)std::tolower(c); }); if (l.find("error") != std::string::npos || l.find("refused") != std::string::npos || l.find("timed out") != std::string::npos || l.find("unreachable") != std::string::npos || l.find("dns") != std::string::npos) { ps.last_error = *it; break; } } } // Record hashrate sample for the chart ps.hashrate_history.push_back(ps.hashrate_10s); ps.hashrate_history_t.push_back(std::time(nullptr)); if (ps.hashrate_history.size() > PoolMiningState::MAX_HISTORY) { ps.hashrate_history.erase(ps.hashrate_history.begin()); ps.hashrate_history_t.erase(ps.hashrate_history_t.begin()); } } else if (xmrig_manager_ && !xmrig_manager_->isRunning()) { state_.pool_mining.xmrig_running = false; state_.pool_mining.running_since = 0; state_.pool_mining.last_error.clear(); 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()) { applyPendingDaemonOutputReset(); 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 " 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(done) / static_cast(scan.witnessReadTotal); } else if (scan.witnessRemaining >= 0) { // Older daemon: bare " 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(scan.witnessRemaining) / static_cast(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 "" 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(witness_seen_txids_.size()) / static_cast(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 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 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 successOpids; std::vector 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(); // A Settings bootstrap that finished while its dialog wasn't drawn (clicked away, minimized): process it // here so the connect and reconcile gates release and the post-bootstrap rescan is armed. ui::BootstrapDownloadDialog::pump(); // 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_ && !long_lane_.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) { // bootstrapped/pruned: rescan from the snapshot base. Refused (a key import or rescan is still // running, the node is busy): keep the reconcile armed and try again in 30 s. if (!runtimeRescan(lowest)) { post_bootstrap_rescan_pending_ = true; post_bootstrap_probe_retry_at_ = std::chrono::steady_clock::now() + std::chrono::seconds(30); } } 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, false)) { setCurrentPage(ui::NavPage::Settings); } // Keyboard shortcut: Ctrl+Left/Right to cycle themes if (io.KeyCtrl && !io.WantTextInput) { bool prevTheme = ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false); bool nextTheme = ImGui::IsKeyPressed(ImGuiKey_RightArrow, false); 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; } } const int n = (int)skins.size(); const int step = prevTheme ? n - 1 : 1; // -1 (mod n) when cycling back int pick = cur; bool applied = false; for (int k = 0; k < n && !applied; k++) { pick = (pick + step) % n; if (!skins[pick].valid) continue; applied = skinMgr.setActiveSkin(skins[pick].id); } if (applied) { if (settings_) { settings_->setSkinId(skins[pick].id); settings_->save(); } ui::Notifications::instance().info(std::string(TR("appx_theme_prefix")) + skins[pick].name); } // else: every skin invalid / failed to load; leave settings untouched, no toast } } } // 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, false)) { 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, false)) { 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, false)) { 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, false)) { 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(); // Replaced textures are retired, not freed: the deferred-destroy queue frees them at the next preFrame(), // after every draw list that may still reference them has been rendered. // ":drgx:" custom chat emoji — themed to the accent like the logo (re-rasterized on theme change). retireTexture(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_); retireTexture(logo_tex_); // retire 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. retireTexture(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(); // Soften the abrupt handoff: tell the user the node is starting + will keep syncing in the // background, instead of dropping them onto a busy/blank main window with no explanation. if (!state_.connected) ui::Notifications::instance().info( settings_->getEncryptionPending() ? TR("wiz_handoff_encrypting") : TR("wiz_handoff_starting"), 10.0f); } // 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(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. tx_revision moves on EVERY content change (incl. a // confirmation flip at equal tx count) and, unlike last_tx_update, lite stamps it. Otherwise this built two unordered_sets over the whole // wallet tx list every frame just to size a badge. if (state_.tx_revision != sb_unconf_key_rev_) { std::unordered_set confirmedTxids; for (const auto& tx : state_.transactions) { if (tx.confirmations >= 1) confirmedTxids.insert(tx.txid); } std::unordered_set 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(unconfirmedTxids.size()); sb_unconf_key_rev_ = state_.tx_revision; } 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(this); else console_exec_ = std::make_unique(this); // One-time: route the console's "Copy secret" through the auto-clearing clipboard. console_tab_.setCopySecretHandler([this](const std::string& s) { copySecretToClipboard(s); }); } 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(a * alpha); v.col = (v.col & ~IM_COL32_A_MASK) | (static_cast(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(a * page_alpha_); v.col = (v.col & ~IM_COL32_A_MASK) | (static_cast(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_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. // Suppressed during a UI sweep (its forced demo state would otherwise paint a banner over every // tab) — except on the one sweep target that exists to capture the banner itself. if ((capture_mode_ && !sweep_show_node_banner_) || 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 in.auth_failed = rpc_auth_failed_; 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::AuthFailed: title = TR("node_banner_auth_title"); icon = ICON_MD_LOCK; 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"); else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) actionLabel = TR("node_banner_open_config"); // The NoPeers case deliberately carries no passthrough detail (connection_status is just // "Connected" there, which reads as a contradiction under the "no peers" headline). Show a // translated, actionable hint instead; every other reason keeps its live status/error text. std::string detailText; if (banner.reason == ui::NodeBannerReason::NoPeers) detailText = TR("node_banner_no_peers_detail"); else if (banner.reason == ui::NodeBannerReason::AuthFailed) detailText = TR("node_banner_auth_detail"); else detailText = banner.detail; 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 (!detailText.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( detailText, 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(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(); else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) { const std::string conf = rpc::Connection::getDefaultConfPath(); util::Platform::openFolder(std::filesystem::path(conf).parent_path().string(), /*createIfMissing=*/false); } } } 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(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 (isLiteBuild() && !state_.sync.lite_error.empty()) { // Lite scan/server failure: show the error instead of a frozen 'Syncing 99.9%'. ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Error()), TR("sb_lite_sync_error"), state_.sync.lite_error.c_str()); } 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; // App-owned smoothed sync-rate estimator: self-resets after a rescan/restart height drop (and on // connect/disconnect via the pending flag), so the ETA never goes stale/absurd ("412h"); days-aware. if (sync_eta_reset_pending_.exchange(false)) sync_eta_.reset(); sync_eta_.update(state_.sync.blocks, ImGui::GetTime()); const ImVec4 syncCol = ImGui::ColorConvertU32ToFloat4(ui::material::Warning()); const long long etaSec = sync_eta_.etaSeconds(blocksLeft); if (etaSec >= 0) { char eta[32]; util::formatEta(etaSec, eta, sizeof(eta)); ImGui::TextColored(syncCol, TR("sb_syncing_eta"), state_.sync.verification_progress * 100.0, blocksLeft, sync_eta_.rate(), eta); } else { ImGui::TextColored(syncCol, 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(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 || notes.consumeHistoryRequest()) { // bell click, or a click on the toast itself 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) { // Every branch below replaces gradient_tex_; the background draw list may already reference the // old one this frame (skin switches happen mid-frame), so free it at the next preFrame(). retireTexture(gradient_tex_); // 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). Retire the current texture (freed at the next preFrame(), after this // frame's draw lists are rendered) so a theme switch doesn't leak it. retireTexture(logo_tex_); logo_loaded_ = false; logo_tex_ = 0; logo_w_ = 0; logo_h_ = 0; coin_logo_loaded_ = false; retireTexture(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 words; // correct order static std::vector> 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)); // Best-effort zero of the seed-word statics (they hold the whole phrase) before they're dropped. auto wipeStr = [](std::string& s) { if (!s.empty()) sodium_memzero(&s[0], s.size()); s.clear(); }; auto wipeWords = [&](std::vector& v) { for (auto& w : v) wipeStr(w); v.clear(); }; auto wipeChips = [&](std::vector>& v) { for (auto& c : v) { wipeStr(c.first); c.second = false; } v.clear(); }; auto finish = [&]() { wallet::secureWipeLiteSecret(seed); sodium_memzero(restoreSeed, sizeof(restoreSeed)); restoreBirthday = 0; restoring = false; restoreErr.clear(); wipeWords(words); wipeChips(chips); progress = 0; step = 0; skipConfirm = false; creating = false; lite_firstrun_dismissed_ = true; ImGui::CloseCurrentPopup(); }; auto splitWords = [](const std::string& s) { std::vector out; std::string w; for (char c : s) { if (std::isspace(static_cast(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))) { wipeChips(chips); 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.0f * ui::Layout::dpiScale(), 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.0f * ui::Layout::dpiScale(), 0))) { skipConfirm = false; step = 1; } ImGui::SameLine(); if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120.0f * ui::Layout::dpiScale(), 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(std::max(0, restoreBirthday)); req.overwrite = lite_wallet_->walletExistsFresh(); // 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.0f * ui::Layout::dpiScale(), 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 = dragonx::util::trimmedClipboardText(clipboard); 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). const double nowClip = ImGui::GetTime(); const bool hoverEnter = pasteHovered && !import_clip_hover_prev_; import_clip_hover_prev_ = pasteHovered; if (pasteHovered && import_key_input_[0] == '\0' && (hoverEnter || nowClip >= import_clip_next_at_)) { import_clip_next_at_ = nowClip + 0.5; // SDL clipboard is a synchronous round-trip // Copy immediately - the backend buffer is freed on the next GetClipboardText() call. std::string t = dragonx::util::trimmedClipboardText(ImGui::GetClipboardText()); 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; import_clip_class_ = pOk ? 1 : pWrong ? 2 : 3; snprintf(import_clip_preview_, sizeof(import_clip_preview_), "%.48s", t.c_str()); sodium_memzero(&t[0], t.size()); // wipe the transient clipboard copy } else { import_clip_class_ = 0; sodium_memzero(import_clip_preview_, sizeof(import_clip_preview_)); } } if (pasteHovered && import_key_input_[0] == '\0' && import_clip_class_ != 0) { ImVec4 col = import_clip_class_ == 1 ? m::SuccessVec4() : import_clip_class_ == 2 ? ImVec4(0.8f, 0.6f, 0.0f, 1.0f) : m::ErrorVec4(); col.w = 0.6f; // ghost (uncommitted) preview const size_t plen = std::strlen(import_clip_preview_); const ImVec2 tp(fieldMin.x + ImGui::GetStyle().FramePadding.x, fieldMin.y + ImGui::GetStyle().FramePadding.y); if (import_key_reveal_) { ImGui::GetWindowDrawList()->AddText(tp, ImGui::ColorConvertFloat4ToU32(col), import_clip_preview_); // revealed: no copy } else { std::string stars(plen, '*'); // not secret ImGui::GetWindowDrawList()->AddText(tp, ImGui::ColorConvertFloat4ToU32(col), stars.c_str()); } } // Wipe the cache the moment it is no longer shown. if (!pasteHovered || import_key_input_[0] != '\0') { if (import_clip_class_ != 0) { sodium_memzero(import_clip_preview_, sizeof(import_clip_preview_)); import_clip_class_ = 0; } } // 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 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"))) { // A private key is a secret — route through the auto-clearing clipboard path (45s armed wipe // + its own "copied, auto-clears" toast) instead of a plain, permanent clipboard write. copySecretToClipboard(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(); 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(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_status_ok_ = true; 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) { // Dialog closed while the fetch was in flight; or a UI sweep holds it open with the demo // phrase, and a real mnemonic landing now would be rendered into its screenshots. if (!show_seed_backup_ || capture_mode_) return; seed_backup_loading_ = false; if (ok) { seed_backup_phrase_ = phrase; // working copy — wiped on close // The user is now looking at their seed via the dedicated backup flow — treat that as // backed up, so the periodic nudge (maybeRemindSeedBackup) stops for good. if (settings_ && !settings_->getSeedBackupReminded()) { settings_->setSeedBackupReminded(true); settings_->save(); } } 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(); { auto words = ui::SplitSeedWords(seed_backup_phrase_); ui::RenderSeedWordGrid(words); ui::WipeSeedWords(words); } ImGui::Spacing(); if (!seed_backup_status_.empty()) { ImGui::PushTextWrapPos(0.0f); ImGui::TextColored(seed_backup_status_ok_ ? ui::material::SuccessVec4() : ImVec4(0.85f, 0.33f, 0.33f, 1.0f), "%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_ = ok; // red when the write failed (was always green before) 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(); // Disarm the inline confirms so an armed state can never survive a close/reopen. seed_migration_confirm_discard_ = false; seed_migration_confirm_sweep_ = false; }; 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; { auto words = ui::SplitSeedWords(seed_migration_seed_); ui::RenderSeedWordGrid(words); ui::WipeSeedWords(words); } } 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::Checkbox(TR("mig_understood"), &seed_migration_understood_); ImGui::Spacing(); ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium()); ImGui::TextWrapped("%s", TR("mig_step1_done")); ImGui::PopStyleColor(); // Continue requires BOTH acknowledgements, and is suppressed while the Discard confirm is armed. const bool ackOk = seed_migration_backed_up_ && seed_migration_understood_; const bool continueDisabled = !ackOk || seed_migration_confirm_discard_; if (continueDisabled) 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 (continueDisabled) ImGui::EndDisabled(); if (!ackOk && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ui::material::Tooltip("%s", TR("mig_ack_required")); ImGui::SameLine(); // Discard now takes a second click: first click arms a confirm, since it deletes the new wallet. if (!seed_migration_confirm_discard_) { if (ui::material::TactileButton(TR("mig_discard"), ImVec2(160 * dp, 0))) seed_migration_confirm_discard_ = true; } if (seed_migration_confirm_discard_) { ImGui::Spacing(); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::ReadableError())); ImGui::TextWrapped("%s", TR("mig_discard_confirm")); ImGui::PopStyleColor(); if (ui::material::TactileButton(TR("mig_discard_yes"), ImVec2(160 * dp, 0))) { // Throw away the isolated new wallet + clear the pending state. (body unchanged) 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(); seed_migration_confirm_discard_ = false; close(); } ImGui::SameLine(); if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0))) seed_migration_confirm_discard_ = false; } 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) seed_migration_confirm_sweep_ = false; // never keep a stale armed state if (!seed_migration_confirm_sweep_) { if (!canSweep) ImGui::BeginDisabled(); if (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0))) seed_migration_confirm_sweep_ = true; // first click arms the confirm 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 } else { // Armed: state the amount being moved before the single sweep transaction fires. char cbuf[160]; snprintf(cbuf, sizeof(cbuf), TR("mig_sweep_confirm"), seed_migration_balance_); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::ReadableError())); ImGui::TextWrapped("%s", cbuf); ImGui::PopStyleColor(); if (!canSweep) ImGui::BeginDisabled(); if (ui::material::TactileButton(TR("mig_sweep_confirm_yes"), ImVec2(170 * dp, 0))) { seed_migration_confirm_sweep_ = false; beginSweepToSeedWallet(); // unchanged } if (!canSweep) ImGui::EndDisabled(); ImGui::SameLine(); if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0))) seed_migration_confirm_sweep_ = false; } } 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 && seed_migration_legacy_remaining_ >= 0.0 && !legacyEmpty) { // Only claim a remainder once the legacy balance was actually measured; while a sweep is // mined but the balance scan is still gated (node syncing), remaining stays at the -1.0 // sentinel, so fall through to the waiting state rather than offering a sweep on an unknown. ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError()); ImGui::TextWrapped("%s", TR("mig_remainder")); ImGui::PopStyleColor(); if (!seed_migration_confirm_sweep_) { if (ui::material::TactileButton(TR("mig_sweep_remaining"), ImVec2(180 * dp, 0))) seed_migration_confirm_sweep_ = true; // first click arms } else { char rcbuf[160]; snprintf(rcbuf, sizeof(rcbuf), TR("mig_sweep_confirm"), seed_migration_legacy_remaining_); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::ReadableError())); ImGui::TextWrapped("%s", rcbuf); ImGui::PopStyleColor(); if (ui::material::TactileButton(TR("mig_sweep_confirm_yes"), ImVec2(180 * dp, 0))) { seed_migration_confirm_sweep_ = false; beginSweepToSeedWallet(); // unchanged } ImGui::SameLine(); if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0))) seed_migration_confirm_sweep_ = false; } } else { ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine")); } // Refresh / Later are hidden while the sweep confirm is armed, to keep that row uncluttered. if (!seed_migration_confirm_sweep_) { ImGui::SameLine(); if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0))) pollSweepStatus(/*forceBalance=*/true); 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..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..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(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(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(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. applyPendingDaemonOutputReset(); feedDaemonBusy(daemon_controller_->outputSince(daemon_output_offset_)); } } void App::applyPendingDaemonOutputReset() { if (daemon_output_reset_pending_.exchange(false)) daemon_output_offset_ = daemon_output_reset_to_.load(); } 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(sizeof(kTabMap)/sizeof(kTabMap[0]))) setCurrentPage(kTabMap[tab]); } std::string App::getDaemonStatus() const { std::lock_guard 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 lk(daemon_status_mutex_); daemon_status_ = std::move(s); } bool App::startEmbeddedDaemon() { if (shutting_down_) return false; // exit in progress: never (re)launch the node 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 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(); // 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(), std::this_thread::get_id() == ui_thread_id_ ? 0 : daemon::EmbeddedDaemon::kDatadirLockWaitMaxPolls); 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 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 lk(app_.stop_client_mutex_); app_.active_stop_client_ = &client_; } ~AppStopRuntime() override { std::lock_guard lk(app_.stop_client_mutex_); app_.active_stop_client_ = nullptr; } double nowSec() override { return std::chrono::duration(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(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(s.cpuPercent + 0.5)); text += buf; } app_.shutdown_status_ = text; app_.stop_idle_minutes_ = static_cast(s.idleSec / 60.0); } private: App& app_; daemon::EmbeddedDaemon* owned_ = nullptr; std::vector 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(choice); if (choice == daemon::StopUserChoice::Detach) { // End a pending stop attempt now rather than after its timeout. std::lock_guard 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 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 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; sync_eta_reset_pending_.store(true, std::memory_order_relaxed); // rescan drops height → reset ETA 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& 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" : long_lane_.active() ? "running a key import or rescan" : "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(); // A running bootstrap starts its abort now, while the shutdown screen is up: one that is replacing the chain // data removes the partial chain first (shouldQuit waits for it), not under main()'s exit watchdog. if (bootstrap_) bootstrap_->cancel(); ui::BootstrapDownloadDialog::cancelForShutdown(); // 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(); long_lane_.abort(); // a key import / rescan waiting with no client timeout; the node's own stop ends its scan 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(); } // Signal the mining-control thread now (no join: the UI thread must not block). The blocking // join-control-thread -> xmrig stop(3000) teardown runs on a background thread (finishMinerShutdown), so // xmrig_manager_ still has a single driver. (M-03 cluster) signalMiningControlStop(); if (xmrig_manager_ && xmrig_manager_->isRunning()) { shutdown_status_ = TR("appx_stopping_pool_miner"); } 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(); } miner_stop_thread_ = std::thread([this]() { finishMinerShutdown(); 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; miner_stop_thread_ = std::thread([this]() { finishMinerShutdown(); 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]() { finishMinerShutdown(); // miner fully stopped before the daemon stop begins 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; }); } bool App::bootstrapQuitPending() const { // Capped: a chain-data removal that is still running after this is left to the bounded join in shutdown(). if (std::chrono::steady_clock::now() - shutdown_start_time_ >= std::chrono::minutes(2)) return false; return (bootstrap_ && !bootstrap_->safeToAbandon()) || ui::BootstrapDownloadDialog::quitPending(); } std::vector App::tailDaemonDebugLog(int maxLines) const { std::vector 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(start), std::ios::beg); std::string chunk(static_cast(sz - start), '\0'); f.read(&chunk[0], static_cast(chunk.size())); chunk.resize(static_cast(f.gcount())); std::vector 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(lines.size()) > maxLines) lines.erase(lines.begin(), lines.end() - static_cast(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(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(static_cast(std::time(nullptr)) - startUtc); } else if (busy_first_seen_ != std::chrono::steady_clock::time_point{}) { secs = std::chrono::duration(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 /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; ext_log_miss_pid_ = 0; ext_log_miss_size_ = 0; ext_log_backoff_ = 0.0; ext_log_retry_at_ = 0.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(from)); s.resize(static_cast(to - from)); in.read(&s[0], static_cast(s.size())); s.resize(static_cast(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; } const double nowLog = ImGui::GetTime(); if (!ext_log_tied_ && pid == ext_log_miss_pid_ && size == ext_log_miss_size_ && nowLog < ext_log_retry_at_) { return; // nothing changed since the last miss, and not yet time to retry } if (!ext_log_tied_ && pid == ext_log_miss_pid_ && size != ext_log_miss_size_ && nowLog < ext_log_retry_at_ && (nowLog - ext_log_miss_since_) >= 15.0) { return; // growing but past the fresh-node grace: honour the backoff even as it grows } 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) { // not that process's log (it may log to its console) if (pid != ext_log_miss_pid_) { // new pid: reset the schedule ext_log_miss_pid_ = pid; ext_log_miss_since_ = nowLog; ext_log_backoff_ = 0.0; } ext_log_miss_size_ = size; // fresh node: banner flushes a beat after the pid; later, back off 4 -> 60 s ext_log_retry_at_ = nowLog + daemon::nextExternalLogRetryDelay(nowLog - ext_log_miss_since_, ext_log_backoff_); return; } daemon_busy_.reset(); ext_log_tied_ = true; ext_log_miss_pid_ = 0; ext_log_miss_size_ = 0; ext_log_backoff_ = 0.0; ext_log_retry_at_ = 0.0; 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(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_; // A key import or rescan on the long-call lane (C61) counts too: stopping the node would cut it off, and // aborting the wallet's wait would not stop the node's scan, so the stop would wait for it anyway. return daemon::shouldConfirmStop(true, ours, rpcBusyWhileAlive() || long_lane_.active()); } bool App::refuseIfNodeBusy() { if (!nodeBusyForStop()) return false; if (long_lane_.active()) { DEBUG_LOGF("[App] refused: a key import or rescan is still running — stopping the node now would cut it off\n"); ui::Notifications::instance().warning(TR("appx_wallet_op_running"), 10.0f); return true; } 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( std::chrono::steady_clock::now() - rpc_busy_since_).count()); const bool rebuilding = busy.rebuilding; const bool walletOp = !rebuilding && long_lane_.active(); // a key import or rescan (C61) if (Type().subtitle1()) ImGui::PushFont(Type().subtitle1()); ImGui::TextUnformatted(TR(rebuilding ? "stopconfirm_rebuild_title" : walletOp ? "stopconfirm_walletop_title" : "stopconfirm_busy_title")); if (Type().subtitle1()) ImGui::PopFont(); ImGui::Spacing(); char body[1024]; if (walletOp) snprintf(body, sizeof(body), "%s", TR("stopconfirm_walletop_body")); else 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(std::chrono::steady_clock::now() - shutdown_start_time_).count(); // One consistent snapshot per frame (M-05). A cancelled bootstrap removing its partial chain holds the // screen (bootstrapQuitPending): say so rather than a stale daemon status. const std::string curShut = bootstrapQuitPending() ? std::string(TR("appx_cleaning_up")) : shutdown_status_.get(); // 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(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)); // Persist refresh-scanned state: ~LiteWalletController never runs on the _Exit path. Bounded (4 s) so the // 15 s watchdog budget holds; the save is tmp + rename, so an abandoned/cut-off save never corrupts. if (lite_wallet_) lite_wallet_->flushOnExit(std::chrono::milliseconds(4000)); // 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. // The background miner teardown (disconnect-only quit paths) is joined first: it is the one driver. if (miner_stop_thread_.joinable()) miner_stop_thread_.join(); // After beginShutdown() the control thread is joined by finishMinerShutdown() (on shutdown_thread_ or // miner_stop_thread_); joining it here too would be two concurrent joins. if (!shutting_down_) 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. Bounded: main()'s 15 s watchdog is running. On the normal path // beginShutdown() cancelled it and the shutdown screen waited out a chain-data replacement // (bootstrapQuitPending), so this returns at once; a worker still running at the deadline is left to the // process exit (never destroyed: ~Bootstrap would join). const auto bootstrapDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); if (bootstrap_) { if (bootstrap_->cancelAndWait(bootstrapDeadline)) bootstrap_.reset(); else (void)bootstrap_.release(); } ui::BootstrapDownloadDialog::abortForShutdown(bootstrapDeadline); // the Settings dialog's own (both exit paths) // 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(); long_lane_.abort(); // else joinAll() waits out a key import / rescan (no client timeout) 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(); } long_lane_.abort(); // beginShutdown() already did; harmless again, and joinAll() must never wait out a scan 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 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((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() ? std::chrono::steady_clock::time_point{} : (std::chrono::steady_clock::now() + std::chrono::seconds(45)); 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(*p)) * 1099511628211ULL; if (h == clipboard_secret_hash_) ImGui::SetClipboardText(""); } clipboard_clear_deadline_ = {}; clipboard_secret_hash_ = 0; } void App::pumpSecretClipboardClear() { if (clipboard_secret_hash_ == 0) return; // armed flag if (std::chrono::steady_clock::now() < 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() { sync_eta_reset_pending_.store(true, std::memory_order_relaxed); 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_restart_pending_.store(false); // a restart is actually happening — clear the reminder 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