diff --git a/CMakeLists.txt b/CMakeLists.txt index a24f267..2b45c8f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -440,6 +440,7 @@ set(APP_SOURCES src/ui/windows/mining_tab_helpers.cpp src/ui/windows/peers_tab.cpp src/ui/windows/network_tab.cpp + src/ui/windows/lite_console_tab.cpp src/ui/windows/explorer_tab.cpp src/ui/windows/market_tab.cpp src/ui/windows/console_tab.cpp diff --git a/src/app.cpp b/src/app.cpp index e65ccb4..dc5616e 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -14,6 +14,7 @@ #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 @@ -29,6 +30,7 @@ #include "ui/windows/mining_tab.h" #include "ui/windows/peers_tab.h" #include "ui/windows/network_tab.h" +#include "ui/windows/lite_console_tab.h" #include "ui/windows/explorer_tab.h" #include "ui/windows/market_tab.h" #include "ui/windows/settings_window.h" @@ -443,10 +445,11 @@ void App::rebuildLiteWallet(bool force) // does not block. The app's auto-open loop then reopens the wallet against the new server. if (!force && lite_wallet_ && lite_wallet_->walletOpen()) return; + const auto liteConn = wallet::liteConnectionSettingsFromAppSettings(*settings_); + wallet::liteLog(std::string(force ? "Lite controller rebuilt" : "Lite controller built") + + " (preferred server: " + liteConn.stickyServerUrl + ")"); lite_wallet_ = wallet::LiteWalletController::createLinked( - walletCapabilities(), - wallet::liteConnectionSettingsFromAppSettings(*settings_), - resolveLiteRolloutDecision(*settings_)); + 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() @@ -1368,6 +1371,9 @@ void App::render() case ui::NavPage::LiteNetwork: ui::RenderLiteNetworkTab(this); break; + case ui::NavPage::LiteConsole: + ui::RenderLiteConsoleTab(this); + break; case ui::NavPage::Explorer: ui::RenderExplorerTab(this); break; diff --git a/src/ui/sidebar.h b/src/ui/sidebar.h index 9b287bb..7a97750 100644 --- a/src/ui/sidebar.h +++ b/src/ui/sidebar.h @@ -30,6 +30,7 @@ enum class NavPage { Market, // --- separator --- Console, + LiteConsole, // lite-only diagnostics console (full-node uses the RPC Console); one per variant Peers, LiteNetwork, // lite-only "Network" tab (full-node uses Peers); exactly one shows per variant Explorer, @@ -52,9 +53,10 @@ inline const NavItem kNavItems[] = { { "History", NavPage::History, nullptr, "history", nullptr }, { "Mining", NavPage::Mining, "TOOLS", "mining", "tools" }, { "Market", NavPage::Market, nullptr, "market", nullptr }, - { "Console", NavPage::Console, "ADVANCED","console", "advanced" }, - { "Network", NavPage::Peers, nullptr, "network", nullptr }, - { "Network", NavPage::LiteNetwork, nullptr, "network", nullptr }, + { "Console", NavPage::Console, "ADVANCED","console", "advanced" }, + { "Console", NavPage::LiteConsole, "ADVANCED","console", "advanced" }, + { "Network", NavPage::Peers, nullptr, "network", nullptr }, + { "Network", NavPage::LiteNetwork, nullptr, "network", nullptr }, { "Explorer", NavPage::Explorer, nullptr, "explorer", nullptr }, { "Settings", NavPage::Settings, nullptr, "settings", nullptr }, }; @@ -79,6 +81,7 @@ inline wallet::WalletUiSurface NavPageSurface(NavPage page) case NavPage::Mining: return wallet::WalletUiSurface::Mining; case NavPage::Market: return wallet::WalletUiSurface::Market; case NavPage::Console: return wallet::WalletUiSurface::Console; + case NavPage::LiteConsole: return wallet::WalletUiSurface::LiteConsole; case NavPage::Peers: return wallet::WalletUiSurface::Peers; case NavPage::LiteNetwork: return wallet::WalletUiSurface::LiteNetwork; case NavPage::Explorer: return wallet::WalletUiSurface::Explorer; @@ -103,6 +106,7 @@ inline const char* GetNavIconMD(NavPage page) case NavPage::Mining: return ICON_MD_CONSTRUCTION; case NavPage::Market: return ICON_MD_TRENDING_UP; case NavPage::Console: return ICON_MD_TERMINAL; + case NavPage::LiteConsole: return ICON_MD_TERMINAL; case NavPage::Peers: return ICON_MD_HUB; case NavPage::LiteNetwork: return ICON_MD_HUB; case NavPage::Explorer: return ICON_MD_EXPLORE; @@ -648,6 +652,7 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei // Click detection bool pageNeedsUnlock = locked && item.page != NavPage::Console && + item.page != NavPage::LiteConsole && item.page != NavPage::Peers && item.page != NavPage::Settings; if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !pageNeedsUnlock) { diff --git a/src/ui/windows/lite_console_tab.cpp b/src/ui/windows/lite_console_tab.cpp new file mode 100644 index 0000000..742bcb6 --- /dev/null +++ b/src/ui/windows/lite_console_tab.cpp @@ -0,0 +1,98 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#include "lite_console_tab.h" + +#include "../../app.h" +#include "../../util/i18n.h" +#include "../../wallet/lite_diagnostics.h" +#include "../layout.h" +#include "../material/type.h" +#include "../material/colors.h" +#include "imgui.h" + +#include +#include +#include + +namespace dragonx { +namespace ui { + +using namespace material; + +namespace { + +// Re-snapshot the (mutex-guarded) log only when it actually changes, not every frame. +std::vector s_lines; +std::uint64_t s_cachedGeneration = static_cast(-1); +bool s_autoScroll = true; + +// Colour error/success lines for at-a-glance scanning (substring match on the messages the +// controller emits). Anything else renders in the muted default colour. +ImU32 lineColor(const std::string& line) +{ + const auto has = [&line](const char* s) { return line.find(s) != std::string::npos; }; + if (has("failed") || has(" unreachable") || has("blocked") || has("could not") || + has("Error") || has("error")) + return Error(); + if (has(": connected") || has("opened") || has("wallet ready") || has("Ready")) + return Success(); + return OnSurfaceMedium(); +} + +} // namespace + +void RenderLiteConsoleTab(App* app) +{ + (void)app; + auto& diag = wallet::LiteDiagnostics::instance(); + const std::uint64_t gen = diag.generation(); + if (gen != s_cachedGeneration) { + s_lines = diag.snapshot(); + s_cachedGeneration = gen; + } + + // ── Header ────────────────────────────────────────────────────────────────── + Type().text(TypeStyle::H6, TR("lite_console_title")); + Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("lite_console_intro")); + ImGui::Spacing(); + + // ── Toolbar ───────────────────────────────────────────────────────────────── + if (ImGui::Button(TR("lite_console_clear"))) diag.clear(); + ImGui::SameLine(); + if (ImGui::Button(TR("lite_console_copy"))) { + std::string all; + for (const auto& l : s_lines) { all += l; all.push_back('\n'); } + ImGui::SetClipboardText(all.c_str()); + } + ImGui::SameLine(); + ImGui::Checkbox(TR("lite_console_autoscroll"), &s_autoScroll); + ImGui::Spacing(); + + // ── Log (terminal-styled scroll region) ───────────────────────────────────── + ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 90)); + ImGui::BeginChild("##LiteConsoleLog", ImVec2(0, 0), true, + ImGuiWindowFlags_HorizontalScrollbar); + ImGui::PushFont(Type().caption()); + if (s_lines.empty()) { + ImGui::PushStyleColor(ImGuiCol_Text, OnSurfaceDisabled()); + ImGui::TextUnformatted(TR("lite_console_empty")); + ImGui::PopStyleColor(); + } else { + for (const auto& line : s_lines) { + ImGui::PushStyleColor(ImGuiCol_Text, lineColor(line)); + ImGui::TextUnformatted(line.c_str()); // not format-interpreted — safe for any content + ImGui::PopStyleColor(); + } + } + // Keep pinned to the newest line only while the user is already at the bottom. + if (s_autoScroll && ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) + ImGui::SetScrollHereY(1.0f); + ImGui::PopFont(); + ImGui::EndChild(); + ImGui::PopStyleColor(); +} + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/lite_console_tab.h b/src/ui/windows/lite_console_tab.h new file mode 100644 index 0000000..5aa7024 --- /dev/null +++ b/src/ui/windows/lite_console_tab.h @@ -0,0 +1,18 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// Lite-wallet "Console" tab: a read-only, terminal-styled view of the lite diagnostics log +// (server connection attempts, wallet open/create/restore, sync milestones, errors). Lite +// builds only. See src/ui/windows/lite_console_tab.cpp. + +#pragma once + +namespace dragonx { +class App; +namespace ui { + +void RenderLiteConsoleTab(App* app); + +} // namespace ui +} // namespace dragonx diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 2831d28..16d7515 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1107,6 +1107,12 @@ void I18n::loadBuiltinEnglish() strings_["xmrig_unknown_error"] = "Unknown error."; // --- Lite Network tab (server browser) --- + strings_["lite_console_title"] = "Console"; + strings_["lite_console_intro"] = "Diagnostic log: server connections, wallet open/create, and sync events."; + strings_["lite_console_clear"] = "Clear"; + strings_["lite_console_copy"] = "Copy"; + strings_["lite_console_autoscroll"] = "Auto-scroll"; + strings_["lite_console_empty"] = "No diagnostic output yet."; strings_["lite_net_title"] = "Lite Servers"; strings_["lite_net_intro"] = "Pick a server to use, or let the wallet choose one at random. Changes apply immediately."; strings_["lite_net_use_random"] = "Use a random server each time"; diff --git a/src/wallet/lite_diagnostics.h b/src/wallet/lite_diagnostics.h new file mode 100644 index 0000000..def6af4 --- /dev/null +++ b/src/wallet/lite_diagnostics.h @@ -0,0 +1,86 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// lite_diagnostics.h — a small, thread-safe in-memory log of lite-wallet diagnostic events +// (server connection attempts, wallet open/create/restore, sync milestones, errors). Written +// from the controller's background threads and the App; read by the lite Console tab. Bounded +// so it can't grow without limit. Header-only (a Meyers singleton) so both the wallet layer +// and the UI can use it with no extra link target. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dragonx { +namespace wallet { + +class LiteDiagnostics { +public: + static LiteDiagnostics& instance() { + static LiteDiagnostics inst; + return inst; + } + + // Append a timestamped line (thread-safe). Safe to call from background threads. + void log(const std::string& message) { + std::string line = timestamp(); + line += message; + std::lock_guard lk(mutex_); + lines_.push_back(std::move(line)); + if (lines_.size() > kMaxLines) lines_.pop_front(); + ++generation_; + } + + // Copy of the current lines (oldest first). + std::vector snapshot() const { + std::lock_guard lk(mutex_); + return std::vector(lines_.begin(), lines_.end()); + } + + // Monotonic counter bumped on every change — lets readers re-snapshot only when it differs. + std::uint64_t generation() const { + std::lock_guard lk(mutex_); + return generation_; + } + + void clear() { + std::lock_guard lk(mutex_); + lines_.clear(); + ++generation_; + } + +private: + LiteDiagnostics() = default; + + static std::string timestamp() { + std::time_t t = std::time(nullptr); + std::tm tmv{}; +#ifdef _WIN32 + localtime_s(&tmv, &t); +#else + localtime_r(&t, &tmv); +#endif + char buf[16]; + std::snprintf(buf, sizeof(buf), "%02d:%02d:%02d ", tmv.tm_hour, tmv.tm_min, tmv.tm_sec); + return std::string(buf); + } + + static constexpr std::size_t kMaxLines = 500; + mutable std::mutex mutex_; + std::deque lines_; + std::uint64_t generation_ = 0; +}; + +// Convenience: append a lite diagnostic line. +inline void liteLog(const std::string& message) { LiteDiagnostics::instance().log(message); } + +} // namespace wallet +} // namespace dragonx diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index 509b594..a2f6aa5 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -4,6 +4,7 @@ #include "lite_wallet_controller.h" +#include "lite_diagnostics.h" #include "../data/wallet_state.h" #include @@ -279,11 +280,16 @@ std::unique_ptr LiteWalletController::createLinked( void LiteWalletController::onLifecycleResult(const LiteWalletLifecycleResult& result) { status_ = result.status; + const std::string op = liteWalletLifecycleOperationName(result.operation); if (result.walletReady) { + liteLog(op + ": wallet ready"); walletOpen_ = true; if (persist_) persist_(); startSync(); // begin background sync on the backend startWorker(); // begin periodic refresh -> WalletState (via takeRefreshedModel) + } else if (result.attempted) { + liteLog(op + " failed: " + + (result.error.empty() ? result.status.message : result.error)); } } @@ -312,6 +318,7 @@ bool LiteWalletController::beginOpenExisting() status_ = WalletBackendStatus{WalletBackendState::Error, reason.empty() ? "lite wallet is not available" : reason, {}, {}, 0.0}; lastOpenError_ = status_.message; + liteLog("Open blocked: " + lastOpenError_); return false; } auto servers = failoverServerUrls(); @@ -319,6 +326,7 @@ bool LiteWalletController::beginOpenExisting() status_ = WalletBackendStatus{WalletBackendState::Error, "no usable lite servers are configured", {}, {}, 0.0}; lastOpenError_ = status_.message; + liteLog("Open blocked: " + lastOpenError_); return false; } @@ -329,6 +337,7 @@ bool LiteWalletController::beginOpenExisting() } openRunning_->store(true); status_ = WalletBackendStatus{WalletBackendState::Connecting, "opening wallet", {}, {}, 0.0}; + liteLog("Opening wallet — trying " + std::to_string(servers.size()) + " server(s)"); // Capture only shared refs + value copies (never `this`) so the thread can safely outlive us. auto bridge = bridge_; @@ -340,14 +349,18 @@ bool LiteWalletController::beginOpenExisting() outcome.error = "could not reach any lite server"; for (const auto& url : servers) { if (!bridge) break; + liteLog(" connecting to " + url + " ..."); // initialize_existing loads the wallet file but contacts the server to start the // light client; ok && non-empty value == ready (mirrors the lifecycle's success test). const auto call = bridge->initializeExisting(/*dangerous=*/false, url); if (call.ok && !call.value.empty()) { + liteLog(" " + url + ": connected"); outcome.ok = true; outcome.serverUrl = url; break; } + const std::string why = call.error.empty() ? "unreachable" : call.error; + liteLog(" " + url + ": " + why); if (!call.error.empty()) outcome.error = call.error; } { @@ -374,12 +387,14 @@ void LiteWalletController::pumpAsyncOpen() walletOpen_ = true; lastOpenError_.clear(); status_ = WalletBackendStatus{WalletBackendState::Ready, "wallet open", {}, {}, 0.0}; + liteLog("Wallet opened via " + outcome.serverUrl); if (persist_) persist_(); startSync(); // begin background sync on the backend startWorker(); // begin periodic refresh -> WalletState } else { lastOpenError_ = outcome.error; status_ = WalletBackendStatus{WalletBackendState::Error, outcome.error, {}, {}, 0.0}; + liteLog("Open failed: " + outcome.error); } } @@ -387,6 +402,7 @@ void LiteWalletController::startSync() { if (syncLaunched_.exchange(true)) return; syncStarted_ = true; + liteLog("Background sync started"); // The backend `sync` command is a blocking, uninterruptible full chain scan, so run it on // a detached thread. Capture shared refs (not the controller) so it is safe to outlive us. auto bridge = bridge_; diff --git a/src/wallet/wallet_capabilities.h b/src/wallet/wallet_capabilities.h index eb1c503..46bfe50 100644 --- a/src/wallet/wallet_capabilities.h +++ b/src/wallet/wallet_capabilities.h @@ -43,7 +43,8 @@ enum class WalletUiSurface { BootstrapDownload, SetupWizard, NodeSettings, - LiteNetwork // lite-wallet-only server browser (replaces Peers in lite builds) + LiteNetwork, // lite-wallet-only server browser (replaces Peers in lite builds) + LiteConsole // lite-wallet-only diagnostics console (full-node uses the RPC Console) }; struct WalletCapabilities { @@ -155,6 +156,7 @@ constexpr bool isUiSurfaceAvailable(const WalletCapabilities& capabilities, case WalletUiSurface::Explorer: return capabilities.fullNodePagesAvailable; case WalletUiSurface::LiteNetwork: + case WalletUiSurface::LiteConsole: return !capabilities.fullNodePagesAvailable; // lite builds only case WalletUiSurface::BootstrapDownload: case WalletUiSurface::SetupWizard: diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 7fc4569..bcd609b 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -25,6 +25,7 @@ #include "util/xmrig_updater.h" #include "util/lite_server_probe.h" #include "wallet/lite_connection_service.h" +#include "wallet/lite_diagnostics.h" #include "wallet/lite_owned_string.h" #include "wallet/lite_rollout_policy.h" #include "wallet/lite_wallet_controller.h" @@ -3560,6 +3561,7 @@ void testLiteWalletControllerOpenFailover() dragonx::test::resetLiteFakeCounters(); dragonx::test::g_liteFakeWalletExists = true; dragonx::test::g_liteFakeDeadServerSubstr = "dead.example"; + LiteDiagnostics::instance().clear(); // verify the open populates the console log LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); EXPECT_TRUE(controller.beginOpenExisting()); @@ -3569,6 +3571,17 @@ void testLiteWalletControllerOpenFailover() controller.pumpAsyncOpen(); EXPECT_TRUE(controller.walletOpen()); EXPECT_TRUE(controller.lastOpenError().empty()); + + // The Console tab reads this log: the failover attempt + success must be recorded. + const auto log = LiteDiagnostics::instance().snapshot(); + EXPECT_TRUE(!log.empty()); + bool sawConnecting = false, sawOpened = false; + for (const auto& l : log) { + if (l.find("connecting to") != std::string::npos) sawConnecting = true; + if (l.find("Wallet opened via") != std::string::npos) sawOpened = true; + } + EXPECT_TRUE(sawConnecting); + EXPECT_TRUE(sawOpened); } // All servers dead -> open fails, wallet stays closed, reason surfaced.