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

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