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

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