From c88b07ef396edb6cbe9330f55175be6a7d0a4e41 Mon Sep 17 00:00:00 2001 From: Bentley Davis <10065854+NonPolynomialTim@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:52:37 -0400 Subject: [PATCH] feat(coop): config-driven multi-rendezvous server selection Move the rendezvous host/ports/version/public-keys out of source into a runtime rendezvous.json holding multiple named servers. The Server Browser gains a top-right combobox to pick the active server; all coop operations use the selection. - rendezvous_config: JSON servers[], atomic active index, per-index accessor; active-server accessors keep their signatures so existing glue is unchanged - DisableableComboBox: ComboBox subclass with per-option enable/disable (setColor face + setTextColor label kept independent) - glue: probeRendezvousServer / probeAllRendezvousServersAsync (punch-free LIST_ROOMS health check) - ServerList: combobox top-right; parallel probes on entry; per-server status drives "(Wait...)"/"(offline)" labels, greyed disabled rows, dimmed button label when the selection is offline, and an animated "Fetching server list ..." / offline warning in a shared textbox; selection persisted by name in rendezvous_selection.json with fallback to the first server - title left-aligned to the left-column widgets Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 + rendezvous.json.example | 23 ++ src/CMakeLists.txt | 1 + src/CoopMod/ServerList.cpp | 260 +++++++++++++- src/CoopMod/ServerList.h | 17 + .../connection_rendezvous_glue.cpp | 53 +++ .../connection_rendezvous_glue.h | 23 ++ .../connectionUDP/rendezvous_config.cpp | 327 ++++++++++++++---- src/CoopMod/connectionUDP/rendezvous_config.h | 75 +++- src/Interface/ComboBox.h | 4 +- src/Interface/DisableableComboBox.cpp | 127 +++++++ src/Interface/DisableableComboBox.h | 57 +++ src/Menu/MainMenuState.cpp | 2 +- src/OpenXcom.2010.vcxproj | 2 + src/OpenXcom.2010.vcxproj.filters | 6 + 15 files changed, 908 insertions(+), 73 deletions(-) create mode 100644 rendezvous.json.example create mode 100644 src/Interface/DisableableComboBox.cpp create mode 100644 src/Interface/DisableableComboBox.h diff --git a/.gitignore b/.gitignore index 57978b516..817932fb6 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,7 @@ bin/user /.kdev4/ /*.kdev4 test.pub +# Real rendezvous config (public keys, but per-deployment; keep out of source control) +rendezvous.json +# Per-user last-selected rendezvous server +rendezvous_selection.json diff --git a/rendezvous.json.example b/rendezvous.json.example new file mode 100644 index 000000000..0c18c8da9 --- /dev/null +++ b/rendezvous.json.example @@ -0,0 +1,23 @@ +{ + "_comment": "Copy to rendezvous.json and fill in real values. Search order: 1) env OXC_RENDEZVOUS_CONFIG, 2) /rendezvous.json, 3) /rendezvous.json. rendezvous.json is git-ignored. SECURITY: only PUBLIC keys go here, never the server secret keys.", + "servers": [ + { + "name": "Official", + "host": "0.0.0.0", + "tcpPort": 39000, + "udpPort": 39001, + "gameVersion": "1.8.4 [v2026-06-28]", + "serverBoxPublicKey": "PASTE_SERVER_BOX_PUBLIC_KEY_BASE64_HERE", + "serverSignPublicKey": "PASTE_SERVER_SIGN_PUBLIC_KEY_BASE64_HERE" + }, + { + "name": "Local Test", + "host": "127.0.0.1", + "tcpPort": 39000, + "udpPort": 39001, + "gameVersion": "1.8.4 [v2026-06-28]", + "serverBoxPublicKey": "PASTE_SERVER_BOX_PUBLIC_KEY_BASE64_HERE", + "serverSignPublicKey": "PASTE_SERVER_SIGN_PUBLIC_KEY_BASE64_HERE" + } + ] +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 148de3fd3..39002930c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -261,6 +261,7 @@ set ( interface_src Interface/Bar.cpp Interface/BattlescapeButton.cpp Interface/ComboBox.cpp + Interface/DisableableComboBox.cpp Interface/Cursor.cpp Interface/FpsCounter.cpp Interface/Frame.cpp diff --git a/src/CoopMod/ServerList.cpp b/src/CoopMod/ServerList.cpp index aa7de46c2..6bf67cb87 100644 --- a/src/CoopMod/ServerList.cpp +++ b/src/CoopMod/ServerList.cpp @@ -18,6 +18,7 @@ */ #include #include +#include #include "ServerList.h" #include "../Engine/Logger.h" #include "../Savegame/SavedGame.h" @@ -27,6 +28,7 @@ #include "../Engine/Options.h" #include "../Engine/Unicode.h" #include "../Mod/Mod.h" +#include "../Mod/RuleInterface.h" #include "../Engine/LocalizedText.h" #include "../Interface/TextButton.h" #include "../Interface/Window.h" @@ -34,10 +36,14 @@ #include "../Interface/TextList.h" #include "../Interface/ToggleTextButton.h" #include "../Interface/ArrowButton.h" +#include "../Interface/DisableableComboBox.h" +#include "../Engine/CrossPlatform.h" +#include #include "HostMenu.h" #include "DirectConnect.h" #include "connectionUDP/connection_rendezvous_glue.h" +#include "connectionUDP/rendezvous_config.h" #include "PasswordCheckMenu.h" #include "ModCheckMenu.h" #include "AddServerMenu.h" @@ -52,6 +58,11 @@ bool _pendingRoomsOk = false; bool _hasPendingRooms = false; bool _isRefreshingServers = false; +// Health-probe results, filled by worker threads and drained on the UI thread. +std::mutex _probeMutex; +std::vector _pendingProbes; +bool _hasPendingProbes = false; + struct compareServerName { bool _reverse; @@ -174,7 +185,7 @@ ServerList::ServerList() : _sortable(true) //_btnCancel = new TextButton(80, 16, 120, 172); - _txtTitle = new Text(310, 17, 5, 7); + _txtTitle = new Text(310, 17, 8, 7); _txtJoin = new Text(310, 9, 5, 23); _txtName = new Text(115, 9, 16, isMobile ? 40 : 32); _txtPlayers = new Text(50, 9, 126, isMobile ? 40 : 32); @@ -290,7 +301,9 @@ ServerList::ServerList() : _sortable(true) _btnCancel->onKeyboardPress((ActionHandler)&ServerList::btnCancelClick, Options::keyCancel); _txtTitle->setBig(); - _txtTitle->setAlign(ALIGN_CENTER); + // Left-aligned so the header clears the rendezvous-server combobox that + // sits in the top-right corner. + _txtTitle->setAlign(ALIGN_LEFT); _txtTitle->setText("SERVER BROWSER"); if (isMobile) @@ -340,8 +353,51 @@ ServerList::ServerList() : _sortable(true) _game->getCoopMod()->setCoopCampaign(false); } + // --- Rendezvous-server selector ------------------------------------------- + { + std::vector names = getRendezvousServerNames(); + + // Restore last-selected server (falls back to the first configured one). + setActiveRendezvousServerByName(loadSavedServerName()); + + _serverStatus.assign(names.size(), 0); // 0 = waiting for probe + _probesStarted = false; + + // Offline warning, hidden until the active server is known unreachable. + _txtOfflineWarning = new Text(298, 25, 8, 60); + add(_txtOfflineWarning, "text", "saveMenus"); + _txtOfflineWarning->setColor(color); + _txtOfflineWarning->setAlign(ALIGN_CENTER); + _txtOfflineWarning->setWordWrap(true); + _txtOfflineWarning->setVisible(false); + + // Palette-correct greyed color for disabled (offline) rows, matching the + // coop mod's OptionsMultiplayer "disabledUserOption" convention. + bool inBattle = _game->getSavedGame() && _game->getSavedGame()->getSavedBattle(); + Uint8 disabledColor = color; + if (RuleInterface* rule = _game->getMod()->getInterface(inBattle ? "battlescape" : "advancedMenu")) + { + if (const Element* el = rule->getElement("disabledUserOption")) + disabledColor = el->color; + } + _serverComboColor = color; + _serverComboDisabledColor = disabledColor; + + // Added LAST so its dropdown draws above every other widget. + _cbxServer = new DisableableComboBox(this, 104, 16, 210, 6); + add(_cbxServer, "button", "saveMenus"); + _cbxServer->setColor(color); + _cbxServer->setDisabledColor(disabledColor); + rebuildServerCombo(); + _cbxServer->onChange((ActionHandler)&ServerList::cbxServerChange); + _cbxServer->setVisible(names.size() > 1); + } + updateServerList(); + // Probe all configured servers so offline ones can be flagged in the combo. + startServerProbes(); + } /** @@ -1170,6 +1226,34 @@ void ServerList::think() { State::think(); + // Apply completed health probes on the main thread. + { + std::vector probes; + { + std::lock_guard lock(_probeMutex); + if (_hasPendingProbes) + { + probes = std::move(_pendingProbes); + _pendingProbes.clear(); + _hasPendingProbes = false; + } + } + + if (!probes.empty()) + { + for (const auto& p : probes) + { + if (p.index < _serverStatus.size()) + _serverStatus[p.index] = p.online ? 1 : 2; + } + rebuildServerCombo(); + updateOfflineWarning(); + } + } + + // Animate "Fetching server list . . ." while the selected server is probing. + updateFetchingAnimation(); + // Handle completed async server-list refresh on the main thread. bool hasPending = false; bool ok = false; @@ -1252,4 +1336,176 @@ void ServerList::think() } } +/** + * Kicks off parallel health probes for every configured rendezvous server. + * Results are collected on worker threads and applied in think(). + */ +void ServerList::startServerProbes() +{ + if (_probesStarted) + return; + _probesStarted = true; + + OpenXcom::probeAllRendezvousServersAsync(2500, + [](OpenXcom::RendezvousProbeResult result) + { + std::lock_guard lock(_probeMutex); + _pendingProbes.push_back(std::move(result)); + _hasPendingProbes = true; + }); +} + +/** + * Rebuilds the rendezvous-server combobox labels/enabled state from the current + * probe status. Waiting servers show " (Wait...)", offline ones " (offline)". + */ +void ServerList::rebuildServerCombo() +{ + if (!_cbxServer) + return; + + std::vector names = getRendezvousServerNames(); + std::vector labels; + std::vector enabled; + labels.reserve(names.size()); + enabled.reserve(names.size()); + + for (size_t i = 0; i < names.size(); ++i) + { + int st = (i < _serverStatus.size()) ? _serverStatus[i] : 0; + std::string suffix = (st == 0) ? " (Wait...)" : (st == 2) ? " (offline)" : ""; + labels.push_back(names[i] + suffix); + enabled.push_back(st == 1); + } + + _cbxServer->setOptions(labels, enabled, false); + // Keep the current selection shown even if it is disabled (offline/waiting). + _cbxServer->forceSelect(getActiveRendezvousServer()); +} + +/** + * Shows a warning and clears the list when the active server is offline; + * hides the warning otherwise. + */ +void ServerList::updateOfflineWarning() +{ + if (!_txtOfflineWarning) + return; + + size_t active = getActiveRendezvousServer(); + int status = (active < _serverStatus.size()) ? _serverStatus[active] : 1; + bool offline = (status == 2); + + // Dim the closed combobox label text (only) when the current selection is + // offline; the button face keeps its default color. + if (_cbxServer) + _cbxServer->setButtonTextColor(offline ? _serverComboDisabledColor : _serverComboColor); + + if (offline) + { + _txtOfflineWarning->setText("Selected rendezvous server is offline. Pick another from the list or direct connect with TCP."); + _txtOfflineWarning->setVisible(true); + _servers.erase( + std::remove_if(_servers.begin(), _servers.end(), + [](const ServerInfo& server) { return !server.added; }), + _servers.end()); + _lstServers->clearList(); + sortList(Options::serverOrder); + } + else if (status == 1) + { + // Online: nothing to warn about. + _txtOfflineWarning->setVisible(false); + } + // status == 0 (still probing): leave the textbox to updateFetchingAnimation(). +} + +/** + * While the selected server's probe is still pending, animate a + * "Fetching server list . . ." message in the same textbox as the offline + * warning, cycling one dot every 0.5s. + */ +void ServerList::updateFetchingAnimation() +{ + if (!_txtOfflineWarning) + return; + + size_t active = getActiveRendezvousServer(); + int status = (active < _serverStatus.size()) ? _serverStatus[active] : 1; + if (status != 0) + return; // only while the active server is still being probed + + static const char* const kDots[4] = { "", " .", " . .", " . . ." }; + int frame = (SDL_GetTicks() / 500) % 4; + + _txtOfflineWarning->setText(std::string("Fetching server list") + kDots[frame]); + _txtOfflineWarning->setVisible(true); +} + +/** + * @return Path of the per-user last-selected rendezvous server file. + */ +std::string ServerList::selectionFilePath() +{ + return Options::getMasterUserFolder() + "rendezvous_selection.json"; +} + +/** + * @return The saved server name, or "" if none/unreadable (caller falls back to + * the first configured server). + */ +std::string ServerList::loadSavedServerName() +{ + std::string path = selectionFilePath(); + if (!OpenXcom::CrossPlatform::fileExists(path)) + return std::string(); + + std::ifstream file(path, std::ifstream::binary); + if (!file.is_open()) + return std::string(); + + Json::Value root; + Json::CharReaderBuilder builder; + std::string errs; + if (!Json::parseFromStream(builder, file, &root, &errs)) + return std::string(); + + return root.get("name", "").asString(); +} + +/** + * Persists the last-selected rendezvous server name. + */ +void ServerList::saveSelectedServerName(const std::string& name) +{ + Json::Value root; + root["name"] = name; + + Json::StreamWriterBuilder builder; + std::ofstream file(selectionFilePath(), std::ofstream::binary); + if (file.is_open()) + file << Json::writeString(builder, root); +} + +/** + * Handler for picking a different rendezvous server from the combobox. + */ +void ServerList::cbxServerChange(Action*) +{ + size_t sel = _cbxServer->getSelected(); + if (!_cbxServer->isEnabled(sel)) + return; // disabled row (offline/waiting) - ignore + + setActiveRendezvousServer(sel); + + std::vector names = getRendezvousServerNames(); + if (sel < names.size()) + saveSelectedServerName(names[sel]); + + _txtOfflineWarning->setVisible(false); + _lstServers->clearList(); + updateServerList(); + updateOfflineWarning(); +} + } diff --git a/src/CoopMod/ServerList.h b/src/CoopMod/ServerList.h index 286a3f6a7..bce66a218 100644 --- a/src/CoopMod/ServerList.h +++ b/src/CoopMod/ServerList.h @@ -33,6 +33,7 @@ class TextList; class TextEdit; class ArrowButton; class ToggleTextButton; +class DisableableComboBox; /** * Base class for saved game screens which @@ -48,6 +49,12 @@ class ServerList : public State Text *_txtTitle, *_txtName, *_txtPlayers, *_txtRegion, *_txtJoin, *_txtPasswordRequired; TextList *_lstServers; ArrowButton *_sortName, *_sortPlayers, *_sortRegion, *_sortPassword; + DisableableComboBox *_cbxServer; + Text *_txtOfflineWarning; + // Per-configured-server probe status: 0 = waiting, 1 = online, 2 = offline. + std::vector _serverStatus; + bool _probesStarted; + Uint8 _serverComboColor, _serverComboDisabledColor; OptionsOrigin _origin; std::vector _servers; // selected @@ -68,6 +75,14 @@ class ServerList : public State bool isAllowedBySearch(std::string serverName); bool isAllowedByFilters(std::string region, bool passwordRequired, bool isUDP, std::string modHash, bool added, bool isCampaign); bool parseUdpPort(const std::string& text, uint16_t& outPort); + // Rendezvous-server selector helpers. + void startServerProbes(); + void rebuildServerCombo(); + void updateOfflineWarning(); + void updateFetchingAnimation(); + std::string selectionFilePath(); + std::string loadSavedServerName(); + void saveSelectedServerName(const std::string& name); public: /// Creates the Saved Game state. ServerList(); @@ -87,6 +102,8 @@ class ServerList : public State void btnHostClick(Action* action); void btnDirectConnectClick(Action* action); void btnAddServerClick(Action* action); + /// Handler for changing the rendezvous-server combobox. + void cbxServerChange(Action* action); /// Handler for moving the mouse over a list item. void lstServerMouseOver(Action *action); /// Handler for moving the mouse outside the list borders. diff --git a/src/CoopMod/connectionUDP/connection_rendezvous_glue.cpp b/src/CoopMod/connectionUDP/connection_rendezvous_glue.cpp index 8037f8567..41e6ca847 100644 --- a/src/CoopMod/connectionUDP/connection_rendezvous_glue.cpp +++ b/src/CoopMod/connectionUDP/connection_rendezvous_glue.cpp @@ -85,6 +85,59 @@ bool isRendezvousConnectionActive() return g_rendezvousFlowActive.load(); } +bool probeRendezvousServer(size_t serverIndex, + uint32_t timeoutMs, + std::vector* outRooms, + std::string* error) +{ + BuiltInRendezvousConfig cfg; + RendezvousClient::ServerKeys keys; + if (!getRendezvousServerConfig(serverIndex, cfg, keys, error)) + return false; + + RendezvousClient::ListConfig lc; + lc.serverHost = cfg.host; + lc.serverTcpPort = cfg.tcpPort; + lc.serverUdpPort = cfg.udpPort; + lc.keys = keys; + lc.timeoutMs = timeoutMs; + lc.gameVersion = cfg.gameVersion; + lc.modHash = std::string(); + lc.compatibleOnly = false; + lc.log = [](const std::string& s) { + DebugLog(("Rendezvous probe: " + s + "\n").c_str()); + }; + // Deliberately no cancelRequested: a health probe is independent of the + // main coop flow's cancel state. + + std::vector rooms; + if (!RendezvousClient::listRooms(lc, rooms, error)) + return false; + + if (outRooms) + *outRooms = std::move(rooms); + return true; +} + +void probeAllRendezvousServersAsync(uint32_t timeoutMs, RendezvousProbeCallback callback) +{ + const size_t count = getRendezvousServerCount(); + for (size_t i = 0; i < count; ++i) + { + std::thread([i, timeoutMs, callback]() { + RendezvousProbeResult res; + res.index = i; + std::string err; + std::vector rooms; + res.online = probeRendezvousServer(i, timeoutMs, &rooms, &err); + if (res.online) + res.rooms = std::move(rooms); + if (callback) + callback(res); + }).detach(); + } +} + static bool readKeyFile(const std::string& path, unsigned char* out, size_t n) { std::ifstream f(path.c_str(), std::ios::binary); diff --git a/src/CoopMod/connectionUDP/connection_rendezvous_glue.h b/src/CoopMod/connectionUDP/connection_rendezvous_glue.h index 42af6d5b5..74a7746cb 100644 --- a/src/CoopMod/connectionUDP/connection_rendezvous_glue.h +++ b/src/CoopMod/connectionUDP/connection_rendezvous_glue.h @@ -13,6 +13,29 @@ namespace OpenXcom using RendezvousBoolCallback = std::function; using RendezvousListCallback = std::function rooms)>; +// Result of a single server health probe (see probeRendezvousServer). +struct RendezvousProbeResult +{ + size_t index = 0; // configured-server index + bool online = false; // answered a signed ROOM_LIST + std::vector rooms; // populated when online +}; +using RendezvousProbeCallback = std::function; + +// Punch-free health check of one configured server (by index). Blocking. +// Sends LIST_ROOMS over TCP with a short timeout; success proves the rendezvous +// service is up and its sign key validates. Does not touch the active server or +// the live coop flow. When outRooms is non-null it receives the room list. +bool probeRendezvousServer(size_t serverIndex, + uint32_t timeoutMs, + std::vector* outRooms, + std::string* error = nullptr); + +// Probes every configured server in parallel (one detached thread each). The +// callback fires once per server as its probe completes, on a worker thread — +// do not modify UI directly from it. +void probeAllRendezvousServersAsync(uint32_t timeoutMs, RendezvousProbeCallback callback); + // True when the current multiplayer flow uses rendezvous/server-list/UDP. // TCP-only disconnects use this to avoid closing stale rendezvous state. bool isRendezvousConnectionActive(); diff --git a/src/CoopMod/connectionUDP/rendezvous_config.cpp b/src/CoopMod/connectionUDP/rendezvous_config.cpp index 436e44472..aa410680c 100644 --- a/src/CoopMod/connectionUDP/rendezvous_config.cpp +++ b/src/CoopMod/connectionUDP/rendezvous_config.cpp @@ -1,98 +1,307 @@ #include "rendezvous_config.h" +#include "../../Engine/CrossPlatform.h" +#include "../../Engine/Options.h" + #include +#include +#include +#include #include +#include +#include namespace OpenXcom { -const char* kRendezvousHost = ""; -const uint16_t kRendezvousTcpPort = 0; -const uint16_t kRendezvousUdpPort = 0; -const char* kRendezvousGameVersion = "1.8.4 [v2026-06-28]"; +namespace +{ + struct RendezvousServerEntry + { + std::string name; + std::string host; + uint16_t tcpPort = 0; + uint16_t udpPort = 0; + std::string gameVersion; + std::string serverBoxPublicKeyB64; + std::string serverSignPublicKeyB64; + }; + + std::once_flag g_loadOnce; + std::vector g_servers; + std::string g_loadedPath; + std::atomic g_activeServer(0); + + std::string joinPath(const std::string& folder, const std::string& file) + { + if (folder.empty()) + return file; + const char last = folder.back(); + if (last == '/' || last == '\\') + return folder + file; + return folder + "/" + file; + } -static const char* kServerBoxPublicKeyBase64 = - ""; // PASTE_SERVER_BOX_PUBLIC_KEY_BASE64_HERE + // Returns the ordered list of candidate config paths. + std::vector candidatePaths() + { + std::vector paths; -static const char* kServerSignPublicKeyBase64 = - ""; // PASTE_SERVER_SIGN_PUBLIC_KEY_BASE64_HERE + if (const char* env = std::getenv("OXC_RENDEZVOUS_CONFIG")) + { + if (env[0] != '\0') + paths.push_back(env); + } -BuiltInRendezvousConfig getBuiltInRendezvousConfig() + // Portable: next to the executable. Easiest for testing/deployment. + paths.push_back(joinPath(CrossPlatform::getExeFolder(), "rendezvous.json")); + + // Alongside the game's other settings. + paths.push_back(joinPath(Options::getConfigFolder(), "rendezvous.json")); + + return paths; + } + + void parseInto(std::istream& in, std::vector& out) + { + Json::Value root; + Json::CharReaderBuilder builder; + std::string errs; + if (!Json::parseFromStream(builder, in, &root, &errs)) + return; // malformed file -> no servers (callers report "not configured") + + const Json::Value& servers = root["servers"]; + if (!servers.isArray()) + return; + + for (Json::ArrayIndex i = 0; i < servers.size(); ++i) + { + const Json::Value& s = servers[i]; + + RendezvousServerEntry e; + e.name = s.get("name", "").asString(); + e.host = s.get("host", "").asString(); + e.tcpPort = static_cast(s.get("tcpPort", 0).asUInt()); + e.udpPort = static_cast(s.get("udpPort", 0).asUInt()); + e.gameVersion = s.get("gameVersion", "").asString(); + e.serverBoxPublicKeyB64 = s.get("serverBoxPublicKey", "").asString(); + e.serverSignPublicKeyB64 = s.get("serverSignPublicKey", "").asString(); + + if (e.name.empty()) + e.name = "Server " + std::to_string(i + 1); + + out.push_back(std::move(e)); + } + } + + void loadImpl() + { + const std::vector paths = candidatePaths(); + if (!paths.empty()) + g_loadedPath = paths.front(); + + for (const std::string& path : paths) + { + std::ifstream f(path.c_str(), std::ios::binary); + if (!f) + continue; + + parseInto(f, g_servers); + g_loadedPath = path; + return; + } + // No file found: g_servers stays empty and callers report "not configured". + } + + const std::vector& servers() + { + std::call_once(g_loadOnce, loadImpl); + return g_servers; + } + + bool decodeBase64Key(const char* fieldName, + const std::string& base64, + unsigned char* out, + size_t outSize, + std::string* error) + { + if (base64.empty()) + { + if (error) + *error = std::string("Rendezvous key not configured: ") + fieldName; + return false; + } + + size_t decodedLen = 0; + if (sodium_base642bin(out, + outSize, + base64.c_str(), + base64.size(), + nullptr, + &decodedLen, + nullptr, + sodium_base64_VARIANT_ORIGINAL) != 0 || + decodedLen != outSize) + { + if (error) + *error = std::string("Invalid rendezvous key: ") + fieldName; + return false; + } + + return true; + } + + bool decodeKeysFor(const RendezvousServerEntry& e, + RendezvousClient::ServerKeys& outKeys, + std::string* error) + { + if (sodium_init() < 0) + { + if (error) + *error = "libsodium init failed"; + return false; + } + + if (!decodeBase64Key("server_box_public_key", + e.serverBoxPublicKeyB64, + outKeys.serverBoxPublicKey.data(), + outKeys.serverBoxPublicKey.size(), + error)) + { + return false; + } + + if (!decodeBase64Key("server_sign_public_key", + e.serverSignPublicKeyB64, + outKeys.serverSignPublicKey.data(), + outKeys.serverSignPublicKey.size(), + error)) + { + return false; + } + + return true; + } + + // Active index clamped to a valid range for the current server list. + size_t activeIndex() + { + const size_t count = servers().size(); + if (count == 0) + return 0; + size_t idx = g_activeServer.load(); + if (idx >= count) + idx = 0; + return idx; + } +} + +std::vector getRendezvousServerNames() { - BuiltInRendezvousConfig cfg; - cfg.host = kRendezvousHost; - cfg.tcpPort = kRendezvousTcpPort; - cfg.udpPort = kRendezvousUdpPort; - cfg.gameVersion = kRendezvousGameVersion; - return cfg; + std::vector names; + for (const auto& e : servers()) + names.push_back(e.name); + return names; +} + +size_t getRendezvousServerCount() +{ + return servers().size(); } -static bool isPlaceholder(const char* s) +size_t getActiveRendezvousServer() { - return !s || std::strstr(s, "PASTE_") != nullptr; + return activeIndex(); } -static bool decodeBase64Key(const char* fieldName, - const char* base64, - unsigned char* out, - size_t outSize, - std::string* error) +std::string getActiveRendezvousServerName() { - if (isPlaceholder(base64)) + const auto& list = servers(); + if (list.empty()) + return std::string(); + return list[activeIndex()].name; +} + +void setActiveRendezvousServer(size_t index) +{ + const size_t count = servers().size(); + if (count == 0) + return; + if (index >= count) + index = 0; + g_activeServer.store(index); +} + +void setActiveRendezvousServerByName(const std::string& name) +{ + const auto& list = servers(); + for (size_t i = 0; i < list.size(); ++i) { - if (error) - *error = std::string("Built-in rendezvous key not configured: ") + fieldName; - return false; + if (list[i].name == name) + { + g_activeServer.store(i); + return; + } } + // Unknown/empty name: fall back to the first configured server. + g_activeServer.store(0); +} - size_t decodedLen = 0; - if (sodium_base642bin(out, - outSize, - base64, - std::strlen(base64), - nullptr, - &decodedLen, - nullptr, - sodium_base64_VARIANT_ORIGINAL) != 0 || - decodedLen != outSize) +bool getRendezvousServerConfig(size_t index, + BuiltInRendezvousConfig& outCfg, + RendezvousClient::ServerKeys& outKeys, + std::string* error) +{ + const auto& list = servers(); + if (index >= list.size()) { if (error) - *error = std::string("Invalid built-in rendezvous key: ") + fieldName; + *error = "Rendezvous server index out of range"; return false; } - return true; + const RendezvousServerEntry& e = list[index]; + outCfg.host = e.host; + outCfg.tcpPort = e.tcpPort; + outCfg.udpPort = e.udpPort; + outCfg.gameVersion = e.gameVersion; + + return decodeKeysFor(e, outKeys, error); +} + +BuiltInRendezvousConfig getBuiltInRendezvousConfig() +{ + BuiltInRendezvousConfig cfg; + const auto& list = servers(); + if (list.empty()) + return cfg; + + const RendezvousServerEntry& e = list[activeIndex()]; + cfg.host = e.host; + cfg.tcpPort = e.tcpPort; + cfg.udpPort = e.udpPort; + cfg.gameVersion = e.gameVersion; + return cfg; } bool loadBuiltInRendezvousKeys(RendezvousClient::ServerKeys& outKeys, std::string* error) { - if (sodium_init() < 0) + const auto& list = servers(); + if (list.empty()) { if (error) - *error = "libsodium init failed"; + *error = "No rendezvous servers configured"; return false; } - if (!decodeBase64Key("server_box_public_key", - kServerBoxPublicKeyBase64, - outKeys.serverBoxPublicKey.data(), - outKeys.serverBoxPublicKey.size(), - error)) - { - return false; - } - - if (!decodeBase64Key("server_sign_public_key", - kServerSignPublicKeyBase64, - outKeys.serverSignPublicKey.data(), - outKeys.serverSignPublicKey.size(), - error)) - { - return false; - } + return decodeKeysFor(list[activeIndex()], outKeys, error); +} - return true; +std::string getRendezvousConfigPath() +{ + servers(); + return g_loadedPath; } -} +} diff --git a/src/CoopMod/connectionUDP/rendezvous_config.h b/src/CoopMod/connectionUDP/rendezvous_config.h index b04b71d25..489cdf740 100644 --- a/src/CoopMod/connectionUDP/rendezvous_config.h +++ b/src/CoopMod/connectionUDP/rendezvous_config.h @@ -1,14 +1,35 @@ #pragma once /* - * Built-in rendezvous configuration for the OpenXcom UDP transport. + * Runtime rendezvous configuration for the OpenXcom UDP transport. * - * Keep these values in one place so UI/menu code does not need to pass the - * rendezvous IP, ports, game version or public key paths every time it calls - * host/list/join helpers. + * Multiple named rendezvous servers are read once, lazily, from a JSON file so a + * deployment/config can be passed around without committing it to source control. + * The Server Browser lets the user pick which one is "active"; all coop operations + * (list/host/join/direct/add) use the active server. + * + * File: rendezvous.json + * { + * "servers": [ + * { + * "name": "Official", + * "host": "", + * "tcpPort": , + * "udpPort": , + * "gameVersion": "", + * "serverBoxPublicKey": "", + * "serverSignPublicKey": "" + * } + * ] + * } + * + * Search order (first file that exists wins): + * 1. path in env var OXC_RENDEZVOUS_CONFIG + * 2. /rendezvous.json + * 3. /rendezvous.json * * SECURITY: - * - Only public keys belong here. + * - Only PUBLIC keys belong in this file. * - Never put server_box_secret.key or server_sign_secret.key in the client. */ @@ -16,13 +37,10 @@ #include #include +#include namespace OpenXcom { -extern const char* kRendezvousHost; -extern const uint16_t kRendezvousTcpPort; -extern const uint16_t kRendezvousUdpPort; -extern const char* kRendezvousGameVersion; struct BuiltInRendezvousConfig { @@ -32,7 +50,46 @@ struct BuiltInRendezvousConfig std::string gameVersion; }; +// --- Server list / selection ------------------------------------------------- + +/// Names of all configured rendezvous servers, in file order. +std::vector getRendezvousServerNames(); + +/// Number of configured rendezvous servers (0 if none / file missing). +size_t getRendezvousServerCount(); + +/// Index of the currently active server (0 if none configured). +size_t getActiveRendezvousServer(); + +/// Name of the currently active server ("" if none configured). +std::string getActiveRendezvousServerName(); + +/// Selects the active server by index. Out-of-range is clamped to a valid index +/// (or ignored if no servers are configured). +void setActiveRendezvousServer(size_t index); + +/// Selects the active server by name. Any unknown/empty name falls back to index 0. +void setActiveRendezvousServerByName(const std::string& name); + +/// Host/ports/version + decoded keys for an arbitrary server index, without +/// changing the active selection (used for offline probing). Returns false and +/// sets *error if the index is invalid or the keys are absent/invalid. +bool getRendezvousServerConfig(size_t index, + BuiltInRendezvousConfig& outCfg, + RendezvousClient::ServerKeys& outKeys, + std::string* error = nullptr); + +// --- Active-server accessors (unchanged signatures) -------------------------- + +/// Host/ports/version of the ACTIVE server (empty if not configured). BuiltInRendezvousConfig getBuiltInRendezvousConfig(); + +/// Decoded public keys of the ACTIVE server. +/// Returns false (and sets *error) if not configured or the keys are invalid. bool loadBuiltInRendezvousKeys(RendezvousClient::ServerKeys& outKeys, std::string* error = nullptr); +/// Absolute path of the config file that was loaded (or the first path probed +/// if none existed). Useful for diagnostics / UI. Empty before first load. +std::string getRendezvousConfigPath(); + } diff --git a/src/Interface/ComboBox.h b/src/Interface/ComboBox.h index dc01de743..670c8637c 100644 --- a/src/Interface/ComboBox.h +++ b/src/Interface/ComboBox.h @@ -35,7 +35,7 @@ class Language; */ class ComboBox : public InteractiveSurface { -private: +protected: static const int HORIZONTAL_MARGIN; static const int VERTICAL_MARGIN; static const int MAX_ITEMS; @@ -88,7 +88,7 @@ class ComboBox : public InteractiveSurface /// Sets the button text without changing the selected option void setText(const std::string &text); /// Sets the selected option in the list. - void setSelected(size_t sel); + virtual void setSelected(size_t sel); /// Sets the list of options. void setOptions(const std::vector &options, bool translate = false); /// Blits the combo box onto another surface. diff --git a/src/Interface/DisableableComboBox.cpp b/src/Interface/DisableableComboBox.cpp new file mode 100644 index 000000000..f8366b62b --- /dev/null +++ b/src/Interface/DisableableComboBox.cpp @@ -0,0 +1,127 @@ +/* + * Copyright 2010-2016 OpenXcom Developers. + * + * This file is part of OpenXcom. + * + * OpenXcom is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OpenXcom is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with OpenXcom. If not, see . + */ +#include "DisableableComboBox.h" +#include "TextList.h" +#include "TextButton.h" + +namespace OpenXcom +{ + +/** + * Sets up the disableable combo box with the specified size and position. + * @param state Pointer to state the combo box belongs to. + * @param width Width in pixels. + * @param height Height in pixels. + * @param x X position in pixels. + * @param y Y position in pixels. + * @param popupAboveButton Popup direction. + */ +DisableableComboBox::DisableableComboBox(State *state, int width, int height, int x, int y, bool popupAboveButton) + : ComboBox(state, width, height, x, y, popupAboveButton), _disabledColor(0) +{ +} + +/** + * + */ +DisableableComboBox::~DisableableComboBox() +{ +} + +/** + * Sets the palette color used to dim disabled options. 0 leaves the default color. + * @param color Palette index. + */ +void DisableableComboBox::setDisabledColor(Uint8 color) +{ + _disabledColor = color; +} + +/** + * Sets the label color of the closed combobox button, leaving the button face + * color unchanged. + * @param color Palette index. + */ +void DisableableComboBox::setButtonTextColor(Uint8 color) +{ + _button->setTextColor(color); +} + +/** + * Changes the list of available options together with a per-option enabled mask. + * Disabled options are dimmed and cannot be selected by the user. + * @param options List of strings. + * @param enabled Per-option enabled flags (missing entries default to enabled). + * @param translate True for a list of string IDs, false for raw strings. + */ +void DisableableComboBox::setOptions(const std::vector &options, const std::vector &enabled, bool translate) +{ + // Store the mask first so the base class's internal setSelected() call + // respects the new enabled state. + _enabled.assign(options.size(), true); + for (size_t i = 0; i < options.size() && i < enabled.size(); ++i) + _enabled[i] = enabled[i]; + + ComboBox::setOptions(options, translate); + + if (_disabledColor != 0) + { + for (size_t i = 0; i < _enabled.size(); ++i) + { + if (!_enabled[i]) + _list->setRowColor(i, _disabledColor); + } + } +} + +/** + * @param idx Option index. + * @return True if the option is enabled/selectable (unknown index defaults to true). + */ +bool DisableableComboBox::isEnabled(size_t idx) const +{ + if (idx < _enabled.size()) + return _enabled[idx]; + return true; +} + +/** + * Selects an option regardless of its enabled state. Used for programmatic + * selection (e.g. restoring a saved, currently-offline server). + * @param idx Option index. + */ +void DisableableComboBox::forceSelect(size_t idx) +{ + ComboBox::setSelected(idx); +} + +/** + * Sets the selected option, but ignores the request when that option is + * disabled. The click-commit path in TextList routes through this virtual, so a + * user click on a disabled row is silently rejected (selection is unchanged). + * @param sel Selected row. + */ +void DisableableComboBox::setSelected(size_t sel) +{ + if (sel < _enabled.size() && !_enabled[sel]) + return; + ComboBox::setSelected(sel); +} + +} diff --git a/src/Interface/DisableableComboBox.h b/src/Interface/DisableableComboBox.h new file mode 100644 index 000000000..d6f6a2b08 --- /dev/null +++ b/src/Interface/DisableableComboBox.h @@ -0,0 +1,57 @@ +#pragma once +/* + * Copyright 2010-2016 OpenXcom Developers. + * + * This file is part of OpenXcom. + * + * OpenXcom is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OpenXcom is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with OpenXcom. If not, see . + */ +#include "ComboBox.h" + +namespace OpenXcom +{ + +/** + * A ComboBox where individual options can be disabled: disabled options are + * drawn in a dimmed color and cannot be picked by the user. Programmatic + * selection (forceSelect) may still land on a disabled option, so a disabled + * item can be shown as the current selection (e.g. an offline server). + */ +class DisableableComboBox : public ComboBox +{ +private: + std::vector _enabled; + Uint8 _disabledColor; +public: + /// Creates a disableable combo box with the specified size and position. + DisableableComboBox(State *state, int width, int height, int x = 0, int y = 0, bool popupAboveButton = false); + /// Cleans up the combo box. + ~DisableableComboBox(); + /// Sets the color used to dim disabled options (0 = leave default color). + void setDisabledColor(Uint8 color); + /// Sets the label color of the (closed) combobox button, leaving the button + /// face color unchanged. Used to dim the text when the current selection is + /// disabled/offline. + void setButtonTextColor(Uint8 color); + /// Sets the list of options together with a per-option enabled mask. + void setOptions(const std::vector &options, const std::vector &enabled, bool translate = false); + /// Returns whether the option at the given index is enabled (selectable). + bool isEnabled(size_t idx) const; + /// Selects an option regardless of its enabled state (bypasses the guard). + void forceSelect(size_t idx); + /// Sets the selected option, ignoring the request if that option is disabled. + void setSelected(size_t sel) override; +}; + +} diff --git a/src/Menu/MainMenuState.cpp b/src/Menu/MainMenuState.cpp index dda52d9d6..75c59ab85 100644 --- a/src/Menu/MainMenuState.cpp +++ b/src/Menu/MainMenuState.cpp @@ -246,7 +246,7 @@ MainMenuState::MainMenuState(bool updateCheck) _textCoopVersion->setAlign(ALIGN_CENTER); _textCoopVersion->setSmall(); - std::string coopVersion = std::string("Coop Mod ") + OpenXcom::kRendezvousGameVersion; + std::string coopVersion = std::string("Coop Mod ") + OpenXcom::getBuiltInRendezvousConfig().gameVersion; _textCoopVersion->setText(coopVersion); } diff --git a/src/OpenXcom.2010.vcxproj b/src/OpenXcom.2010.vcxproj index 3d013d2c9..b10f05d34 100644 --- a/src/OpenXcom.2010.vcxproj +++ b/src/OpenXcom.2010.vcxproj @@ -584,6 +584,7 @@ + @@ -1081,6 +1082,7 @@ + diff --git a/src/OpenXcom.2010.vcxproj.filters b/src/OpenXcom.2010.vcxproj.filters index f0b17d948..6324a0af5 100644 --- a/src/OpenXcom.2010.vcxproj.filters +++ b/src/OpenXcom.2010.vcxproj.filters @@ -678,6 +678,9 @@ Interface + + Interface + Menu @@ -1937,6 +1940,9 @@ Interface + + Interface + Menu