From 732d892d4dc40e1f10b13a2c136aedd0c3de194d Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 7 Jun 2026 11:09:27 -0500 Subject: [PATCH] =?UTF-8?q?feat(lite):=20ObsidianDragonLite=20Network=20ta?= =?UTF-8?q?b=20=E2=80=94=20server=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lite-wallet-only "Network" tab (full-node keeps the Peers tab; exactly one shows per variant) to manage lightwalletd servers, replacing the basic selector that was in Settings. - Card list of servers with per-server latency + status dot, DNS host + resolved IP, and an Official/Custom pill. Official DragonX servers get a glowing outline. - Pick a server (Sticky) by clicking its card, or toggle "use a random server" (Random mode); selection applies immediately (App::rebuildLiteWallet(force=true) tears down + rebuilds the controller against the new server and resyncs — its dtor detaches the uninterruptible sync thread, so this doesn't block). - Add custom servers; hide/unhide servers (persisted set, revealed by a "Show hidden" toggle). - Latency/IP come from a new background probe (util/LiteServerProbe): libcurl CONNECT_ONLY does the TCP+TLS handshake (works for gRPC lightwalletd, no HTTP response needed), recording APPCONNECT_TIME as latency and CURLINFO_PRIMARY_IP. Auto-runs on tab open + a Refresh button. Wiring: WalletUiSurface::LiteNetwork (gated !fullNodePagesAvailable) + NavPage::LiteNetwork in the sidebar + app.cpp dispatch; settings gains a hidden-servers set; isOfficialLiteServer() added to lite_connection_service. The Settings page lite-server selector + its plumbing are removed (single source of truth = the tab). Reuses the existing server model (LiteServerPreference, Sticky/Random, selectLiteServer) and UI primitives (DrawGlassPanel, ThemeEffects glow, peers-tab ping-dot idiom). Unit-tested (liteServerHost, isOfficialLiteServer) + an env-gated live probe (verified vs lite.dragonx.is: online, latency, IP). Both variants + lite-backend build; suite passes; hygiene clean; GUI smoke-launched without crash. Co-Authored-By: Claude Opus 4.8 --- CMakeLists.txt | 3 + src/app.cpp | 20 +- src/app.h | 2 +- src/config/settings.cpp | 7 + src/config/settings.h | 7 + src/ui/pages/settings_page.cpp | 168 +-------------- src/ui/sidebar.h | 6 +- src/ui/windows/network_tab.cpp | 285 +++++++++++++++++++++++++ src/ui/windows/network_tab.h | 18 ++ src/util/i18n.cpp | 20 ++ src/util/lite_server_probe.cpp | 96 +++++++++ src/util/lite_server_probe.h | 63 ++++++ src/wallet/lite_connection_service.cpp | 8 + src/wallet/lite_connection_service.h | 2 + src/wallet/wallet_capabilities.h | 5 +- tests/test_phase4.cpp | 51 +++++ 16 files changed, 589 insertions(+), 172 deletions(-) create mode 100644 src/ui/windows/network_tab.cpp create mode 100644 src/ui/windows/network_tab.h create mode 100644 src/util/lite_server_probe.cpp create mode 100644 src/util/lite_server_probe.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 8eca99f..4b0f8c1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -439,6 +439,7 @@ set(APP_SOURCES src/ui/windows/mining_pool_panel.cpp src/ui/windows/mining_tab_helpers.cpp src/ui/windows/peers_tab.cpp + src/ui/windows/network_tab.cpp src/ui/windows/explorer_tab.cpp src/ui/windows/market_tab.cpp src/ui/windows/console_tab.cpp @@ -483,6 +484,7 @@ set(APP_SOURCES src/daemon/lifecycle_adapters.cpp src/daemon/xmrig_manager.cpp src/util/bootstrap.cpp + src/util/lite_server_probe.cpp src/util/xmrig_updater.cpp src/util/xmrig_updater_core.cpp src/util/secure_vault.cpp @@ -987,6 +989,7 @@ if(BUILD_TESTING) src/util/secure_vault.cpp src/util/platform.cpp src/util/logger.cpp + src/util/lite_server_probe.cpp src/util/xmrig_updater.cpp src/util/xmrig_updater_core.cpp ${MINIZ_SOURCES} diff --git a/src/app.cpp b/src/app.cpp index f1e1012..33ae2e0 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -28,6 +28,7 @@ #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/explorer_tab.h" #include "ui/windows/market_tab.h" #include "ui/windows/settings_window.h" @@ -429,14 +430,18 @@ wallet::LiteRolloutDecision resolveLiteRolloutDecision(config::Settings& setting } } // namespace -void App::rebuildLiteWallet() +void App::rebuildLiteWallet(bool force) { if (!supportsLiteBackend() || !settings_) return; - // Don't tear down a live session: 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 now - // would discard the open wallet and its in-flight, uninterruptible sync. - if (lite_wallet_ && lite_wallet_->walletOpen()) return; + // Don't tear down a live session unless forced: if a wallet is already open (and possibly + // mid-sync), the new server selection is already persisted to settings and will take effect + // the next time the controller is built (next launch, or before another wallet is opened). + // Rebuilding would discard the open wallet and its in-flight, uninterruptible sync — which is + // exactly what the Network tab's apply-immediately server switch wants (force=true). Replacing + // lite_wallet_ destroys the old controller; its destructor detaches the uninterruptible sync + // thread (which keeps the shared bridge alive) and only joins the short poll worker, so this + // does not block. The app's auto-open loop then reopens the wallet against the new server. + if (!force && lite_wallet_ && lite_wallet_->walletOpen()) return; lite_wallet_ = wallet::LiteWalletController::createLinked( walletCapabilities(), @@ -1318,6 +1323,9 @@ void App::render() case ui::NavPage::Peers: ui::RenderPeersTab(this); break; + case ui::NavPage::LiteNetwork: + ui::RenderLiteNetworkTab(this); + break; case ui::NavPage::Explorer: ui::RenderExplorerTab(this); break; diff --git a/src/app.h b/src/app.h index 696efd4..21fc7f9 100644 --- a/src/app.h +++ b/src/app.h @@ -161,7 +161,7 @@ public: void requestLiteUnlock() { lite_unlock_prompt_ = true; } // (Re)build the lite controller from current settings so a changed lite-server selection // takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp). - void rebuildLiteWallet(); + void rebuildLiteWallet(bool force = false); WalletState& state() { return state_; } const WalletState& state() const { return state_; } const WalletState& getWalletState() const { return state_; } diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 6c55e0f..98505da 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -229,6 +229,11 @@ bool Settings::load(const std::string& path) if (lite.contains("install_id") && lite["install_id"].is_string()) { lite_install_id_ = lite["install_id"].get(); } + if (lite.contains("hidden_servers") && lite["hidden_servers"].is_array()) { + lite_hidden_servers_.clear(); + for (const auto& u : lite["hidden_servers"]) + if (u.is_string()) lite_hidden_servers_.insert(u.get()); + } } if (j.contains("verbose_logging")) verbose_logging_ = j["verbose_logging"].get(); if (j.contains("debug_categories") && j["debug_categories"].is_array()) { @@ -365,6 +370,8 @@ bool Settings::save(const std::string& path) } lite["rollout_override"] = lite_rollout_override_; lite["install_id"] = lite_install_id_; + lite["hidden_servers"] = json::array(); + for (const auto& u : lite_hidden_servers_) lite["hidden_servers"].push_back(u); j["lite_wallet"] = lite; } j["verbose_logging"] = verbose_logging_; diff --git a/src/config/settings.h b/src/config/settings.h index 60c48be..04dc966 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -244,6 +244,12 @@ public: const std::vector& getLiteServers() const { return lite_servers_; } void setLiteServers(const std::vector& servers) { lite_servers_ = servers; } + // Lite servers the user has hidden from the Network tab (kept by URL, shown via a toggle). + const std::set& getLiteHiddenServers() const { return lite_hidden_servers_; } + bool isLiteServerHidden(const std::string& url) const { return lite_hidden_servers_.count(url) > 0; } + void hideLiteServer(const std::string& url) { lite_hidden_servers_.insert(url); } + void unhideLiteServer(const std::string& url) { lite_hidden_servers_.erase(url); } + // Lite wallet rollout / kill-switch (see wallet/lite_rollout_policy.h). // Override: "auto" (honor rollout manifest), "force_on", or "force_off". std::string getLiteRolloutOverride() const { return lite_rollout_override_; } @@ -415,6 +421,7 @@ private: {"https://lite4.dragonx.is", "DragonX Lite 4", true}, {"https://lite5.dragonx.is", "DragonX Lite 5", true} }; + std::set lite_hidden_servers_; // server URLs hidden from the Network tab bool verbose_logging_ = false; std::set debug_categories_; diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index a01988a..003c1c9 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -105,12 +105,6 @@ struct SettingsPageState { LowSpecSnapshot low_spec_snapshot; bool keep_daemon_running = false; bool stop_external_daemon = false; - int lite_server_mode = 0; - char lite_server_url[256] = "https://lite.dragonx.is"; - int lite_random_seed = 0; - bool lite_persist_selected_server = true; - std::vector lite_servers; - std::string lite_server_status; bool lite_lifecycle_expanded = false; int lite_lifecycle_operation = 0; char lite_wallet_path[256] = ""; @@ -151,28 +145,6 @@ struct SettingsPageState { static SettingsPageState s_settingsState; -static void copyToSettingsBuffer(char* dest, std::size_t destSize, const std::string& value) { - if (!dest || destSize == 0) return; - std::strncpy(dest, value.c_str(), destSize - 1); - dest[destSize - 1] = '\0'; -} - -static wallet::LiteConnectionSettings liteConnectionSettingsFromPageState(config::Settings* settings) { - wallet::LiteConnectionSettings connectionSettings = settings - ? wallet::liteConnectionSettingsFromAppSettings(*settings) - : wallet::defaultLiteConnectionSettings(); - if (!s_settingsState.lite_servers.empty()) { - connectionSettings.servers = s_settingsState.lite_servers; - } - connectionSettings.selectionMode = s_settingsState.lite_server_mode == 1 - ? wallet::LiteServerSelectionMode::Random - : wallet::LiteServerSelectionMode::Sticky; - connectionSettings.stickyServerUrl = s_settingsState.lite_server_url; - connectionSettings.chainName = wallet::kDragonXLiteChainName; - connectionSettings.randomSelectionSeed = static_cast(std::max(0, s_settingsState.lite_random_seed)); - return connectionSettings; -} - static wallet::LiteWalletLifecycleOperation liteLifecycleOperationFromPageState() { switch (s_settingsState.lite_lifecycle_operation) { case 1: return wallet::LiteWalletLifecycleOperation::OpenExisting; @@ -181,42 +153,6 @@ static wallet::LiteWalletLifecycleOperation liteLifecycleOperationFromPageState( } } -static void saveLiteServerSelectionFromPageState(App* app) { - if (!app || !app->settings()) return; - - const auto connectionSettings = liteConnectionSettingsFromPageState(app->settings()); - wallet::LiteWalletServerSelectionUiExecutionInput input; - input.capabilities = app->walletCapabilities(); - input.intent.selectedServerIntentProvided = true; - input.intent.selectionMode = connectionSettings.selectionMode; - input.intent.selectedServerUrl = connectionSettings.stickyServerUrl; - input.intent.randomSelectionSeed = connectionSettings.randomSelectionSeed; - input.intent.chainName = connectionSettings.chainName; - input.intent.replaceServers = true; - input.intent.servers = connectionSettings.servers; - input.persistence.settingsLoaded = true; - input.persistence.havePersistedSelectionIntent = true; - input.persistence.persistSelectedServer = s_settingsState.lite_persist_selected_server; - input.persistence.persistenceOwnerReady = true; - input.persistence.writeSettings = true; - input.ui.selectedServerDisplayReady = true; - input.ui.lifecycleUiOwnerReady = true; - input.ui.operationConfirmed = true; - input.ui.privateDataRedactionReady = true; - input.ui.syncPlannerFeedReady = true; - input.requireLifecycleReadiness = false; - - const auto result = wallet::executeLiteWalletServerSelectionUi(*app->settings(), input); - if (result.settingsWritten) { - s_settingsState.lite_server_status = "Saved"; - // Rebuild the lite controller so the newly-saved server actually takes effect (it is - // otherwise captured once at startup). No-op if a wallet is already open mid-session. - app->rebuildLiteWallet(); - } else if (!result.error.empty()) { - s_settingsState.lite_server_status = result.error; - Notifications::instance().warning(result.error); - } -} static void evaluateLiteLifecycleRequestFromPageState(App* app) { if (!app || !app->settings()) return; @@ -359,17 +295,7 @@ static void loadSettingsPageState(config::Settings* settings) { Layout::setUserFontScale(s_settingsState.font_scale); // sync with Layout on load s_settingsState.keep_daemon_running = settings->getKeepDaemonRunning(); s_settingsState.stop_external_daemon = settings->getStopExternalDaemon(); - { - const auto liteSettings = wallet::liteConnectionSettingsFromAppSettings(*settings); - s_settingsState.lite_server_mode = liteSettings.selectionMode == wallet::LiteServerSelectionMode::Random ? 1 : 0; - copyToSettingsBuffer(s_settingsState.lite_server_url, - sizeof(s_settingsState.lite_server_url), - liteSettings.stickyServerUrl); - s_settingsState.lite_random_seed = static_cast(liteSettings.randomSelectionSeed); - s_settingsState.lite_persist_selected_server = settings->getLitePersistSelectedServer(); - s_settingsState.lite_servers = liteSettings.servers; - s_settingsState.lite_server_status.clear(); - } + // Lite-server selection is managed entirely by the Network tab (not the Settings page). s_settingsState.mine_when_idle = settings->getMineWhenIdle(); s_settingsState.mine_idle_delay = settings->getMineIdleDelay(); s_settingsState.idle_thread_scaling = settings->getIdleThreadScaling(); @@ -424,11 +350,7 @@ static void saveSettingsPageState(config::Settings* settings) { settings->setFontScale(s_settingsState.font_scale); settings->setKeepDaemonRunning(s_settingsState.keep_daemon_running); settings->setStopExternalDaemon(s_settingsState.stop_external_daemon); - { - auto liteSettings = liteConnectionSettingsFromPageState(settings); - wallet::applyLiteConnectionSettingsToAppSettings(*settings, liteSettings); - settings->setLitePersistSelectedServer(s_settingsState.lite_persist_selected_server); - } + // Lite-server selection is owned by the Network tab; the Settings page no longer writes it. settings->setMineWhenIdle(s_settingsState.mine_when_idle); settings->setMineIdleDelay(s_settingsState.mine_idle_delay); settings->setIdleThreadScaling(s_settingsState.idle_thread_scaling); @@ -1558,90 +1480,10 @@ void RenderSettingsPage(App* app) { if (app->isLiteBuild()) { float liteLabelW = std::min(leftColW * 0.35f, 132.0f); float liteInputW = std::max(80.0f, leftColW - liteLabelW - Layout::spacingSm()); - const char* modeLabels[] = {"Sticky", "Random"}; - ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y)); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted("Mode"); - ImGui::SameLine(leftX - sectionOrigin.x + liteLabelW); - ImGui::SetNextItemWidth(liteInputW); - if (ImGui::BeginCombo("##LiteServerMode", modeLabels[s_settingsState.lite_server_mode == 1 ? 1 : 0])) { - for (int modeIndex = 0; modeIndex < 2; ++modeIndex) { - const bool selected = s_settingsState.lite_server_mode == modeIndex; - if (ImGui::Selectable(modeLabels[modeIndex], selected)) { - s_settingsState.lite_server_mode = modeIndex; - saveLiteServerSelectionFromPageState(app); - } - if (selected) ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } - - ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y)); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted("Preset"); - ImGui::SameLine(leftX - sectionOrigin.x + liteLabelW); - std::string presetPreview = s_settingsState.lite_server_url[0] != '\0' - ? std::string(s_settingsState.lite_server_url) - : std::string("Select"); - for (const auto& server : s_settingsState.lite_servers) { - if (server.url == s_settingsState.lite_server_url && !server.label.empty()) { - presetPreview = server.label; - break; - } - } - ImGui::SetNextItemWidth(liteInputW); - if (ImGui::BeginCombo("##LiteServerPreset", presetPreview.c_str())) { - for (const auto& server : s_settingsState.lite_servers) { - if (!server.enabled) continue; - const std::string label = server.label.empty() ? server.url : server.label; - const bool selected = server.url == s_settingsState.lite_server_url; - if (ImGui::Selectable(label.c_str(), selected)) { - copyToSettingsBuffer(s_settingsState.lite_server_url, - sizeof(s_settingsState.lite_server_url), - server.url); - s_settingsState.lite_server_mode = 0; - saveLiteServerSelectionFromPageState(app); - } - if (selected) ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } - - ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y)); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted("Server"); - ImGui::SameLine(leftX - sectionOrigin.x + liteLabelW); - ImGui::SetNextItemWidth(liteInputW); - ImGui::InputText("##LiteServerUrl", s_settingsState.lite_server_url, - sizeof(s_settingsState.lite_server_url)); - if (ImGui::IsItemDeactivatedAfterEdit()) { - s_settingsState.lite_server_mode = 0; - saveLiteServerSelectionFromPageState(app); - } - - if (s_settingsState.lite_server_mode == 1) { - ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y)); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted("Seed"); - ImGui::SameLine(leftX - sectionOrigin.x + liteLabelW); - ImGui::SetNextItemWidth(std::min(160.0f, liteInputW)); - if (ImGui::InputInt("##LiteRandomSeed", &s_settingsState.lite_random_seed)) { - if (s_settingsState.lite_random_seed < 0) s_settingsState.lite_random_seed = 0; - } - if (ImGui::IsItemDeactivatedAfterEdit()) saveLiteServerSelectionFromPageState(app); - } - - ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y)); - if (ImGui::Checkbox("Persist selected server##LitePersistServer", - &s_settingsState.lite_persist_selected_server)) { - saveLiteServerSelectionFromPageState(app); - } - - if (!s_settingsState.lite_server_status.empty()) { - Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), - s_settingsState.lite_server_status.c_str()); - } + // Lite-server selection lives in the dedicated Network tab now. + Type().textColored(TypeStyle::Body2, OnSurfaceMedium(), + "Lite servers are managed in the Network tab."); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); if (ImGui::Button("Lite wallet request##LiteLifecycleToggle", ImVec2(liteInputW, 0))) { diff --git a/src/ui/sidebar.h b/src/ui/sidebar.h index 9381934..9b287bb 100644 --- a/src/ui/sidebar.h +++ b/src/ui/sidebar.h @@ -31,6 +31,7 @@ enum class NavPage { // --- separator --- Console, Peers, + LiteNetwork, // lite-only "Network" tab (full-node uses Peers); exactly one shows per variant Explorer, Settings, Count_ @@ -52,7 +53,8 @@ inline const NavItem kNavItems[] = { { "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::Peers, nullptr, "network", nullptr }, + { "Network", NavPage::LiteNetwork, nullptr, "network", nullptr }, { "Explorer", NavPage::Explorer, nullptr, "explorer", nullptr }, { "Settings", NavPage::Settings, nullptr, "settings", nullptr }, }; @@ -78,6 +80,7 @@ inline wallet::WalletUiSurface NavPageSurface(NavPage page) case NavPage::Market: return wallet::WalletUiSurface::Market; case NavPage::Console: return wallet::WalletUiSurface::Console; case NavPage::Peers: return wallet::WalletUiSurface::Peers; + case NavPage::LiteNetwork: return wallet::WalletUiSurface::LiteNetwork; case NavPage::Explorer: return wallet::WalletUiSurface::Explorer; case NavPage::Settings: return wallet::WalletUiSurface::Settings; default: return wallet::WalletUiSurface::Overview; @@ -101,6 +104,7 @@ inline const char* GetNavIconMD(NavPage page) case NavPage::Market: return ICON_MD_TRENDING_UP; case NavPage::Console: 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; case NavPage::Settings: return ICON_MD_SETTINGS; default: return ICON_MD_HOME; diff --git a/src/ui/windows/network_tab.cpp b/src/ui/windows/network_tab.cpp new file mode 100644 index 0000000..e6be08e --- /dev/null +++ b/src/ui/windows/network_tab.cpp @@ -0,0 +1,285 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#include "network_tab.h" + +#include "../../app.h" +#include "../../config/settings.h" +#include "../../util/i18n.h" +#include "../../util/lite_server_probe.h" +#include "../../wallet/lite_connection_service.h" +#include "../theme.h" +#include "../layout.h" +#include "../material/type.h" +#include "../material/draw_helpers.h" +#include "../material/colors.h" +#include "../effects/theme_effects.h" +#include "../../embedded/IconsMaterialDesign.h" +#include "imgui.h" + +#include +#include +#include +#include +#include + +namespace dragonx { +namespace ui { + +using namespace material; +using LiteMode = config::Settings::LiteServerSelectionPreferenceMode; + +namespace { + +util::LiteServerProbe s_probe; +bool s_probeStarted = false; +bool s_showHidden = false; +char s_addUrl[256] = ""; +char s_addLabel[128] = ""; +std::string s_addError; + +// (Re)probe every server in the list (visible + hidden), so latency is ready when unhidden too. +void startProbe(config::Settings* st) +{ + std::vector urls; + for (const auto& s : st->getLiteServers()) urls.push_back(s.url); + s_probe.start(std::move(urls)); +} + +ImU32 latencyColor(int ms) +{ + if (ms < 100) return Success(); + if (ms < 500) return Warning(); + return Error(); +} + +} // namespace + +void RenderLiteNetworkTab(App* app) +{ + if (!app || !app->settings()) return; + config::Settings* st = app->settings(); + const float dp = Layout::dpiScale(); + + if (!s_probeStarted) { startProbe(st); s_probeStarted = true; } + const auto probe = s_probe.results(); + + const bool randomMode = st->getLiteServerSelectionMode() == LiteMode::Random; + const std::string sticky = st->getLiteStickyServerUrl(); + + auto applyAndRebuild = [&]() { st->save(); app->rebuildLiteWallet(/*force=*/true); }; + auto pinServer = [&](const std::string& url) { + st->setLiteServerSelectionMode(LiteMode::Sticky); + st->setLiteStickyServerUrl(url); + applyAndRebuild(); + }; + + ImFont* sub2 = Type().subtitle2(); + ImFont* body2 = Type().body2(); + ImFont* cap = Type().caption(); + + // ── Header ─────────────────────────────────────────────────────────────── + Type().text(TypeStyle::H6, TR("lite_net_title")); + Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("lite_net_intro")); + ImGui::Spacing(); + + bool useRandom = randomMode; + if (ImGui::Checkbox(TR("lite_net_use_random"), &useRandom)) { + st->setLiteServerSelectionMode(useRandom ? LiteMode::Random : LiteMode::Sticky); + applyAndRebuild(); + } + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 110.0f * dp); + if (TactileButton(TR("lite_net_refresh"), ImVec2(100.0f * dp, 0))) startProbe(st); + if (randomMode) + Type().textColored(TypeStyle::Caption, Primary(), TR("lite_net_random_active")); + ImGui::Spacing(); + + // ── Add custom server ────────────────────────────────────────────────────── + { + const float availW = ImGui::GetContentRegionAvail().x; + const float addBtnW = 80.0f * dp; + const float urlW = (availW - addBtnW - 16.0f * dp) * 0.6f; + const float lblW = (availW - addBtnW - 16.0f * dp) * 0.4f; + ImGui::SetNextItemWidth(urlW); + ImGui::InputTextWithHint("##LiteAddUrl", TR("lite_net_add_url_hint"), s_addUrl, sizeof(s_addUrl)); + ImGui::SameLine(); + ImGui::SetNextItemWidth(lblW); + ImGui::InputTextWithHint("##LiteAddLabel", TR("lite_net_add_label_hint"), s_addLabel, sizeof(s_addLabel)); + ImGui::SameLine(); + if (TactileButton(TR("lite_net_add"), ImVec2(addBtnW, 0))) { + std::string url = s_addUrl; + if (!wallet::isLiteServerUrlUsable(url)) { + s_addError = TR("lite_net_invalid_url"); + } else { + auto servers = st->getLiteServers(); + bool exists = false; + for (const auto& s : servers) if (s.url == url) { exists = true; break; } + if (!exists) { + config::Settings::LiteServerPreference p; + p.url = url; + p.label = s_addLabel[0] ? std::string(s_addLabel) : url; + p.enabled = true; + servers.push_back(p); + st->setLiteServers(servers); + st->save(); + startProbe(st); + } + s_addUrl[0] = '\0'; s_addLabel[0] = '\0'; s_addError.clear(); + } + } + if (!s_addError.empty()) + Type().textColored(TypeStyle::Caption, Error(), s_addError.c_str()); + } + ImGui::Spacing(); + + // ── Card renderer ────────────────────────────────────────────────────────── + const float cardH = 58.0f * dp; + const float pad = 12.0f * dp; + const float gap = 8.0f * dp; + const float hideW = 44.0f * dp; + ImDrawList* dl = ImGui::GetWindowDrawList(); + + auto drawCard = [&](const config::Settings::LiteServerPreference& sv, bool hiddenList) { + const std::string host = util::liteServerHost(sv.url); + const bool official = wallet::isOfficialLiteServer(sv.url); + const bool selected = !randomMode && sv.url == sticky; + + util::LiteServerProbeResult pr; + auto it = probe.find(sv.url); + if (it != probe.end()) pr = it->second; + + const float cardW = ImGui::GetContentRegionAvail().x; + ImVec2 cardMin = ImGui::GetCursorScreenPos(); + ImVec2 cardMax(cardMin.x + cardW, cardMin.y + cardH); + const float rnd = 8.0f * dp; + const float cardBtnW = cardW - hideW; + + // Background + selection/official emphasis. + GlassPanelSpec spec; + spec.rounding = rnd; + spec.fillAlpha = selected ? 34 : 18; + DrawGlassPanel(dl, cardMin, cardMax, spec); + if (official) { + auto& fx = effects::ThemeEffects::instance(); + if (fx.isEnabled()) { + fx.drawGlowPulse(dl, cardMin, cardMax, rnd); + } + // Always-visible static outline so officials are distinguishable even without effects. + float pulse = 0.75f + 0.25f * (float)std::sin(ImGui::GetTime() * 2.0); + dl->AddRect(cardMin, cardMax, WithAlpha(Primary(), (int)(150 * pulse)), rnd, 0, 1.6f * dp); + } + if (selected) + dl->AddRectFilled(cardMin, ImVec2(cardMin.x + 3.0f * dp, cardMax.y), Primary(), rnd); + + // Main click area selects the server (left of the hide strip). + ImGui::SetCursorScreenPos(cardMin); + ImGui::InvisibleButton(("##litecard_" + sv.url).c_str(), ImVec2(cardBtnW, cardH)); + const bool cardHovered = ImGui::IsItemHovered(); + const bool cardClicked = ImGui::IsItemClicked(); + if (cardHovered && !hiddenList) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (!selected) dl->AddRectFilled(cardMin, cardMax, WithAlpha(OnSurface(), 8), rnd); + } + if (cardClicked && !hiddenList) pinServer(sv.url); + + // Left: label + host + ip. + float tx = cardMin.x + pad + 4.0f * dp; + dl->AddText(sub2, sub2->LegacySize, ImVec2(tx, cardMin.y + 9.0f * dp), + OnSurface(), sv.label.c_str()); + std::string sub = host; + if (!pr.ip.empty() && pr.ip != host) sub += " · " + pr.ip; + dl->AddText(cap, cap->LegacySize, ImVec2(tx, cardMin.y + 9.0f * dp + sub2->LegacySize + 3.0f * dp), + OnSurfaceMedium(), sub.c_str()); + + // Right (within the card-button area): latency dot + text, official/custom pill. + float rx = cardMin.x + cardBtnW - pad; + // latency text + char lat[24]; + ImU32 latCol; + if (!pr.probed) { snprintf(lat, sizeof(lat), "%s", TR("lite_net_checking")); latCol = OnSurfaceDisabled(); } + else if (!pr.online) { snprintf(lat, sizeof(lat), "%s", TR("lite_net_offline")); latCol = Error(); } + else { snprintf(lat, sizeof(lat), "%dms", pr.latencyMs); latCol = latencyColor(pr.latencyMs); } + ImVec2 latSz = cap->CalcTextSizeA(cap->LegacySize, FLT_MAX, 0, lat); + float latX = rx - latSz.x; + dl->AddText(cap, cap->LegacySize, ImVec2(latX, cardMin.y + cardH * 0.5f + 2.0f * dp), latCol, lat); + // status dot left of latency text + if (pr.probed) { + float dotR = 3.5f * dp; + dl->AddCircleFilled(ImVec2(latX - dotR - 4.0f * dp, cardMin.y + cardH * 0.5f + 2.0f * dp + cap->LegacySize * 0.5f), + dotR, pr.online ? latencyColor(pr.latencyMs) : Error()); + } + // official/custom + in-use pills (top-right) + const char* tag = official ? TR("lite_net_official") : TR("lite_net_custom"); + ImU32 tagBg = official ? WithAlpha(Primary(), 40) : WithAlpha(Secondary(), 30); + ImU32 tagFg = official ? WithAlpha(Primary(), 230) : WithAlpha(Secondary(), 220); + ImVec2 tagSz = cap->CalcTextSizeA(cap->LegacySize, FLT_MAX, 0, tag); + float tagX = rx - tagSz.x; + float tagY = cardMin.y + 9.0f * dp; + dl->AddRectFilled(ImVec2(tagX - 6 * dp, tagY - 1 * dp), ImVec2(rx + 6 * dp, tagY + tagSz.y + 1 * dp), + tagBg, 4.0f * dp); + dl->AddText(cap, cap->LegacySize, ImVec2(tagX, tagY), tagFg, tag); + if (selected) { + const char* inUse = TR("lite_net_in_use"); + ImVec2 uSz = cap->CalcTextSizeA(cap->LegacySize, FLT_MAX, 0, inUse); + dl->AddText(cap, cap->LegacySize, ImVec2(tagX - uSz.x - 10 * dp, tagY), Success(), inUse); + } + + // Hide / unhide button (far-right strip). + ImVec2 hideMin(cardMin.x + cardBtnW, cardMin.y); + ImGui::SetCursorScreenPos(hideMin); + ImGui::InvisibleButton(("##litehide_" + sv.url).c_str(), ImVec2(hideW, cardH)); + bool hideHov = ImGui::IsItemHovered(); + if (hideHov) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::SetTooltip("%s", hiddenList ? TR("lite_net_unhide") : TR("lite_net_hide")); + } + if (ImGui::IsItemClicked()) { + if (hiddenList) st->unhideLiteServer(sv.url); + else st->hideLiteServer(sv.url); + st->save(); + } + ImFont* ico = Type().iconSmall(); + const char* eye = hiddenList ? ICON_MD_VISIBILITY : ICON_MD_VISIBILITY_OFF; + ImVec2 icoSz = ico->CalcTextSizeA(ico->LegacySize, FLT_MAX, 0, eye); + dl->AddText(ico, ico->LegacySize, + ImVec2(hideMin.x + (hideW - icoSz.x) * 0.5f, cardMin.y + (cardH - icoSz.y) * 0.5f), + hideHov ? OnSurface() : OnSurfaceDisabled(), eye); + + ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y + gap)); + }; + + // ── Visible servers ──────────────────────────────────────────────────────── + ImVec2 scrollAvail = ImGui::GetContentRegionAvail(); + ImGui::BeginChild("##LiteServerScroll", scrollAvail, false, + ImGuiWindowFlags_NoBackground); + for (const auto& sv : st->getLiteServers()) { + if (st->isLiteServerHidden(sv.url)) continue; + drawCard(sv, /*hiddenList=*/false); + } + + // ── Hidden servers (toggle) ───────────────────────────────────────────────── + { + bool anyHidden = false; + for (const auto& sv : st->getLiteServers()) + if (st->isLiteServerHidden(sv.url)) { anyHidden = true; break; } + if (anyHidden) { + ImGui::Spacing(); + ImGui::Checkbox(TR("lite_net_show_hidden"), &s_showHidden); + if (s_showHidden) { + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("lite_net_hidden_section")); + ImGui::Spacing(); + for (const auto& sv : st->getLiteServers()) { + if (!st->isLiteServerHidden(sv.url)) continue; + drawCard(sv, /*hiddenList=*/true); + } + } + } + } + ImGui::EndChild(); + + (void)body2; +} + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/network_tab.h b/src/ui/windows/network_tab.h new file mode 100644 index 0000000..5d9c737 --- /dev/null +++ b/src/ui/windows/network_tab.h @@ -0,0 +1,18 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// Lite-wallet "Network" tab: a card list of lightwalletd servers with latency + host/IP, an +// official-server glow, random-vs-pinned selection, add-custom-server, and hide/unhide. Lite +// builds only (full-node uses the Peers tab). See src/ui/windows/network_tab.cpp. + +#pragma once + +namespace dragonx { +class App; +namespace ui { + +void RenderLiteNetworkTab(App* app); + +} // namespace ui +} // namespace dragonx diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 25c47d6..e80f115 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1061,6 +1061,26 @@ void I18n::loadBuiltinEnglish() strings_["xmrig_update_failed"] = "Update failed"; strings_["xmrig_unknown_error"] = "Unknown error."; + // --- Lite Network tab (server browser) --- + 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"; + strings_["lite_net_random_active"] = "Random server selection is active."; + strings_["lite_net_refresh"] = "Refresh"; + strings_["lite_net_add"] = "Add"; + strings_["lite_net_add_url_hint"] = "https://your-lite-server"; + strings_["lite_net_add_label_hint"] = "Label (optional)"; + strings_["lite_net_invalid_url"] = "Enter a valid http(s):// URL."; + strings_["lite_net_official"] = "Official"; + strings_["lite_net_custom"] = "Custom"; + strings_["lite_net_in_use"] = "In use"; + strings_["lite_net_offline"] = "offline"; + strings_["lite_net_checking"] = "checking…"; + strings_["lite_net_hide"] = "Hide"; + strings_["lite_net_unhide"] = "Unhide"; + strings_["lite_net_show_hidden"] = "Show hidden servers"; + strings_["lite_net_hidden_section"] = "Hidden servers"; + // --- Peers Tab --- strings_["peers_avg_ping"] = "Avg Ping"; strings_["peers_ban_24h"] = "Ban Peer 24h"; diff --git a/src/util/lite_server_probe.cpp b/src/util/lite_server_probe.cpp new file mode 100644 index 0000000..af71bf8 --- /dev/null +++ b/src/util/lite_server_probe.cpp @@ -0,0 +1,96 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#include "lite_server_probe.h" + +#include "logger.h" + +#include + +#include + +namespace dragonx { +namespace util { + +std::string liteServerHost(const std::string& url) +{ + // Strip scheme ("scheme://") then take everything up to the first '/', '?' or '#'. + std::string s = url; + const auto scheme = s.find("://"); + if (scheme != std::string::npos) s = s.substr(scheme + 3); + const auto end = s.find_first_of("/?#"); + if (end != std::string::npos) s = s.substr(0, end); + // Drop any userinfo ("user@host"). + const auto at = s.find('@'); + if (at != std::string::npos) s = s.substr(at + 1); + return s; +} + +LiteServerProbe::~LiteServerProbe() +{ + cancel_ = true; + if (worker_.joinable()) worker_.join(); +} + +void LiteServerProbe::start(std::vector urls) +{ + // Replace any in-flight probe. + cancel_ = true; + if (worker_.joinable()) worker_.join(); + cancel_ = false; + busy_ = true; + worker_ = std::thread([this, urls = std::move(urls)]() mutable { run(std::move(urls)); }); +} + +std::map LiteServerProbe::results() const +{ + std::lock_guard lk(mutex_); + return results_; +} + +void LiteServerProbe::run(std::vector urls) +{ + for (const auto& url : urls) { + if (cancel_.load()) break; + + LiteServerProbeResult r; + r.probed = true; + + CURL* c = curl_easy_init(); + if (c) { + curl_easy_setopt(c, CURLOPT_URL, url.c_str()); + curl_easy_setopt(c, CURLOPT_CONNECT_ONLY, 1L); // TCP+TLS handshake only + curl_easy_setopt(c, CURLOPT_CONNECTTIMEOUT, 5L); + curl_easy_setopt(c, CURLOPT_TIMEOUT, 8L); + curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L); // thread-safe resolver + curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 0L); + curl_easy_setopt(c, CURLOPT_USERAGENT, "ObsidianDragon/1.0"); + curl_easy_setopt(c, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(c, CURLOPT_SSL_VERIFYHOST, 2L); + + const CURLcode res = curl_easy_perform(c); + if (res == CURLE_OK) { + r.online = true; + double t = 0.0; + // Prefer TLS-handshake-complete time; fall back to TCP connect time. + if (curl_easy_getinfo(c, CURLINFO_APPCONNECT_TIME, &t) != CURLE_OK || t <= 0.0) + curl_easy_getinfo(c, CURLINFO_CONNECT_TIME, &t); + r.latencyMs = t > 0.0 ? static_cast(t * 1000.0) : 0; + char* ip = nullptr; + if (curl_easy_getinfo(c, CURLINFO_PRIMARY_IP, &ip) == CURLE_OK && ip) + r.ip = ip; + } else { + DEBUG_LOGF("[lite-probe] %s: %s\n", url.c_str(), curl_easy_strerror(res)); + } + curl_easy_cleanup(c); + } + + std::lock_guard lk(mutex_); + results_[url] = r; + } + busy_ = false; +} + +} // namespace util +} // namespace dragonx diff --git a/src/util/lite_server_probe.h b/src/util/lite_server_probe.h new file mode 100644 index 0000000..d600d3a --- /dev/null +++ b/src/util/lite_server_probe.h @@ -0,0 +1,63 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// LiteServerProbe — background latency/IP probe for lite (lightwalletd) servers. +// +// The lite backend only exposes a yes/no checkServerOnline, so the Network tab measures latency +// itself: for each server URL, a libcurl CONNECT_ONLY request does the TCP+TLS handshake (which +// works for gRPC/HTTP-2 lightwalletd endpoints without needing an HTTP response) and records the +// handshake time as latency plus the resolved IP. Runs on a background thread; results are read +// thread-safely from the UI thread and re-run on demand (Refresh). + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace dragonx { +namespace util { + +struct LiteServerProbeResult { + bool probed = false; // a probe has completed for this URL + bool online = false; // the TCP+TLS handshake succeeded + int latencyMs = 0; // handshake latency (ms); valid when online + std::string ip; // resolved primary IP (CURLINFO_PRIMARY_IP) +}; + +// Pure helper: the host[:port] authority of a URL (scheme + path stripped), for display. +// "https://lite.dragonx.is:443/x" -> "lite.dragonx.is:443"; "" on a malformed input. +std::string liteServerHost(const std::string& url); + +class LiteServerProbe { +public: + LiteServerProbe() = default; + ~LiteServerProbe(); + LiteServerProbe(const LiteServerProbe&) = delete; + LiteServerProbe& operator=(const LiteServerProbe&) = delete; + + // Probe each URL on a background thread (replacing any in-flight probe). Results stream in as + // each server completes; poll results() each frame. + void start(std::vector urls); + + // Snapshot of results so far, keyed by URL. + std::map results() const; + + bool busy() const { return busy_.load(); } + +private: + void run(std::vector urls); + + mutable std::mutex mutex_; + std::map results_; + std::atomic busy_{false}; + std::atomic cancel_{false}; + std::thread worker_; +}; + +} // namespace util +} // namespace dragonx diff --git a/src/wallet/lite_connection_service.cpp b/src/wallet/lite_connection_service.cpp index 1389442..67b3ae4 100644 --- a/src/wallet/lite_connection_service.cpp +++ b/src/wallet/lite_connection_service.cpp @@ -87,6 +87,14 @@ bool isLiteServerUrlUsable(const std::string& serverUrl) return startsWith(normalized, "https://") || startsWith(normalized, "http://"); } +bool isOfficialLiteServer(const std::string& serverUrl) +{ + const std::string url = trimCopy(serverUrl); + for (const auto& s : defaultDragonXLiteServers()) + if (s.url == url) return true; + return false; +} + const char* liteServerSelectionModeName(LiteServerSelectionMode mode) { switch (mode) { diff --git a/src/wallet/lite_connection_service.h b/src/wallet/lite_connection_service.h index ead24e8..ecabded 100644 --- a/src/wallet/lite_connection_service.h +++ b/src/wallet/lite_connection_service.h @@ -87,6 +87,8 @@ struct LiteServerHealthCheckResult { std::vector defaultDragonXLiteServers(); LiteConnectionSettings defaultLiteConnectionSettings(); bool isLiteServerUrlUsable(const std::string& serverUrl); +// True if the URL is one of the built-in official DragonX lite servers (gets a glowing outline). +bool isOfficialLiteServer(const std::string& serverUrl); const char* liteServerSelectionModeName(LiteServerSelectionMode mode); const char* liteConnectionAvailabilityName(LiteConnectionAvailability availability); LiteServerSelectionResult selectLiteServer(const LiteConnectionSettings& settings); diff --git a/src/wallet/wallet_capabilities.h b/src/wallet/wallet_capabilities.h index 9bda755..eb1c503 100644 --- a/src/wallet/wallet_capabilities.h +++ b/src/wallet/wallet_capabilities.h @@ -42,7 +42,8 @@ enum class WalletUiSurface { Settings, BootstrapDownload, SetupWizard, - NodeSettings + NodeSettings, + LiteNetwork // lite-wallet-only server browser (replaces Peers in lite builds) }; struct WalletCapabilities { @@ -153,6 +154,8 @@ constexpr bool isUiSurfaceAvailable(const WalletCapabilities& capabilities, case WalletUiSurface::Peers: case WalletUiSurface::Explorer: return capabilities.fullNodePagesAvailable; + case WalletUiSurface::LiteNetwork: + return !capabilities.fullNodePagesAvailable; // lite builds only case WalletUiSurface::BootstrapDownload: case WalletUiSurface::SetupWizard: case WalletUiSurface::NodeSettings: diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index dad0be8..c9d3dc5 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -21,6 +21,8 @@ #include "util/amount_format.h" #include "util/payment_uri.h" #include "util/xmrig_updater.h" +#include "util/lite_server_probe.h" +#include "wallet/lite_connection_service.h" #include "wallet/lite_owned_string.h" #include "wallet/lite_rollout_policy.h" #include "wallet/lite_wallet_controller.h" @@ -4076,6 +4078,52 @@ void testXmrigPinnedKeyValidity() EXPECT_EQ(n, static_cast(crypto_sign_PUBLICKEYBYTES)); } +// Lite Network tab pure helpers. +void testLiteServerHostParsing() +{ + using dragonx::util::liteServerHost; + EXPECT_EQ(liteServerHost("https://lite.dragonx.is"), std::string("lite.dragonx.is")); + EXPECT_EQ(liteServerHost("https://lite.dragonx.is:443/path"), std::string("lite.dragonx.is:443")); + EXPECT_EQ(liteServerHost("http://1.2.3.4:9067"), std::string("1.2.3.4:9067")); + EXPECT_EQ(liteServerHost("https://user@host.example/x?y"), std::string("host.example")); + EXPECT_EQ(liteServerHost("lite.dragonx.is"), std::string("lite.dragonx.is")); // no scheme +} + +void testLiteOfficialServerDetection() +{ + using dragonx::wallet::isOfficialLiteServer; + EXPECT_TRUE(isOfficialLiteServer("https://lite.dragonx.is")); + EXPECT_TRUE(isOfficialLiteServer("https://lite3.dragonx.is")); + EXPECT_TRUE(isOfficialLiteServer(" https://lite.dragonx.is ")); // trimmed + EXPECT_FALSE(isOfficialLiteServer("https://my-custom-server.example")); + EXPECT_FALSE(isOfficialLiteServer("")); +} + +// Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture. +void testLiteServerProbeLive() +{ + if (!std::getenv("DRAGONX_TEST_NETWORK")) { + std::printf("[skip] testLiteServerProbeLive (set DRAGONX_TEST_NETWORK=1 to run)\n"); + return; + } + using namespace dragonx::util; + LiteServerProbe probe; + probe.start({"https://lite.dragonx.is"}); + for (int i = 0; i < 150 && probe.busy(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + const auto res = probe.results(); + const auto it = res.find("https://lite.dragonx.is"); + EXPECT_TRUE(it != res.end()); + if (it != res.end()) { + EXPECT_TRUE(it->second.probed); + if (it->second.online) { // reachable -> latency + IP captured + EXPECT_TRUE(!it->second.ip.empty()); + std::printf("[live] lite.dragonx.is online %dms ip=%s\n", + it->second.latencyMs, it->second.ip.c_str()); + } + } +} + void testXmrigSignatureVerify() { using namespace dragonx::util; @@ -4229,6 +4277,9 @@ int main() testXmrigSignatureAssetSelection(); testXmrigPinnedKeyValidity(); testXmrigSignatureVerify(); + testLiteServerHostParsing(); + testLiteOfficialServerDetection(); + testLiteServerProbeLive(); testXmrigLiveInstall(); testGeneratedResourceBehavior();