feat(lite): ObsidianDragonLite Network tab — server browser

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 11:09:27 -05:00
parent afd612be7e
commit 732d892d4d
16 changed files with 589 additions and 172 deletions

View File

@@ -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}

View File

@@ -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;

View File

@@ -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_; }

View File

@@ -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<std::string>();
}
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<std::string>());
}
}
if (j.contains("verbose_logging")) verbose_logging_ = j["verbose_logging"].get<bool>();
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_;

View File

@@ -244,6 +244,12 @@ public:
const std::vector<LiteServerPreference>& getLiteServers() const { return lite_servers_; }
void setLiteServers(const std::vector<LiteServerPreference>& servers) { lite_servers_ = servers; }
// Lite servers the user has hidden from the Network tab (kept by URL, shown via a toggle).
const std::set<std::string>& 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<std::string> lite_hidden_servers_; // server URLs hidden from the Network tab
bool verbose_logging_ = false;
std::set<std::string> debug_categories_;

View File

@@ -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<wallet::LiteServerEndpoint> 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::size_t>(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<int>(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))) {

View File

@@ -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;

View File

@@ -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 <cmath>
#include <cstdio>
#include <map>
#include <string>
#include <vector>
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<std::string> 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

View File

@@ -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

View File

@@ -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";

View File

@@ -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 <curl/curl.h>
#include <utility>
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<std::string> 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<std::string, LiteServerProbeResult> LiteServerProbe::results() const
{
std::lock_guard<std::mutex> lk(mutex_);
return results_;
}
void LiteServerProbe::run(std::vector<std::string> 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<int>(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<std::mutex> lk(mutex_);
results_[url] = r;
}
busy_ = false;
}
} // namespace util
} // namespace dragonx

View File

@@ -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 <atomic>
#include <map>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
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<std::string> urls);
// Snapshot of results so far, keyed by URL.
std::map<std::string, LiteServerProbeResult> results() const;
bool busy() const { return busy_.load(); }
private:
void run(std::vector<std::string> urls);
mutable std::mutex mutex_;
std::map<std::string, LiteServerProbeResult> results_;
std::atomic<bool> busy_{false};
std::atomic<bool> cancel_{false};
std::thread worker_;
};
} // namespace util
} // namespace dragonx

View File

@@ -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) {

View File

@@ -87,6 +87,8 @@ struct LiteServerHealthCheckResult {
std::vector<LiteServerEndpoint> 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);

View File

@@ -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:

View File

@@ -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<std::size_t>(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();