diff options
| -rw-r--r-- | indra/llcorehttp/llwebsocketmgr.cpp | 27 | ||||
| -rw-r--r-- | indra/llcorehttp/llwebsocketmgr.h | 13 | ||||
| -rw-r--r-- | indra/newview/CMakeLists.txt | 2 | ||||
| -rw-r--r-- | indra/newview/llpreviewscript.cpp | 74 | ||||
| -rw-r--r-- | indra/newview/llpreviewscript.h | 3 | ||||
| -rw-r--r-- | indra/newview/llscripteditorws.cpp | 608 | ||||
| -rw-r--r-- | indra/newview/llscripteditorws.h | 185 |
7 files changed, 877 insertions, 35 deletions
diff --git a/indra/llcorehttp/llwebsocketmgr.cpp b/indra/llcorehttp/llwebsocketmgr.cpp index 0427bdab54..3e4262b49b 100644 --- a/indra/llcorehttp/llwebsocketmgr.cpp +++ b/indra/llcorehttp/llwebsocketmgr.cpp @@ -30,6 +30,7 @@ #include "llerror.h" #include "llsdserialize.h" #include "llhost.h" +#include "llsdjson.h" #include <websocketpp/config/boost_config.hpp> #include <websocketpp/config/asio_no_tls.hpp> @@ -242,14 +243,16 @@ struct Server_impl */ bool start() { - if (!mServer.stopped()) - { - LL_WARNS("WebSocket") << "WebSocket server is already running" << LL_ENDL; - return false; - } + //if (!mServer.stopped()) + //{ + // LL_WARNS("WebSocket") << "WebSocket server is already running" << LL_ENDL; + // return false; + //} try { + LL_INFOS("WebSocket") << "Starting WebSocket server on port " << mPort + << (mLocalOnly ? " (localhost only)" : " (all interfaces)") << LL_ENDL; mServer.start_accept(); // Run controlled event loop with periodic stop flag checking @@ -592,6 +595,7 @@ void LLWebsocketMgr::WSServer::handleOpenConnection(const connection_h& handle) { LL_WARNS("WebSocket") << mServerName << " failed to create connection object" << LL_ENDL; return; + } // Removed redundant assignment to mConnections[handle] size = mConnections.size(); } @@ -641,7 +645,7 @@ void LLWebsocketMgr::WSServer::handleMessage(const connection_h& handle, const s } //------------------------------------------------------------------------ -bool LLWebsocketMgr::WSConnection::sendMessage(const std::string& message) +bool LLWebsocketMgr::WSConnection::sendMessage(const std::string& message) const { if (!mServer) { @@ -651,6 +655,17 @@ bool LLWebsocketMgr::WSConnection::sendMessage(const std::string& message) return mServer->sendMessageTo(mConnectionHandle, message); } +bool LLWebsocketMgr::WSConnection::sendMessage(const boost::json::value& json) const +{ + std::string message = boost::json::serialize(json); + return sendMessage(message); +} + +bool LLWebsocketMgr::WSConnection::sendMessage(const LLSD& data) const +{ + return sendMessage(LlsdToJson(data)); +} + void LLWebsocketMgr::WSConnection::closeConnection(U16 code, const std::string& reason) { if (!mServer) diff --git a/indra/llcorehttp/llwebsocketmgr.h b/indra/llcorehttp/llwebsocketmgr.h index 5634acde01..08f6f22dbc 100644 --- a/indra/llcorehttp/llwebsocketmgr.h +++ b/indra/llcorehttp/llwebsocketmgr.h @@ -39,6 +39,8 @@ #include <thread> #include <atomic> +#include <boost/json.hpp> + #include <websocketpp/common/connection_hdl.hpp> struct Server_impl; @@ -148,7 +150,9 @@ public: * connection->sendMessage(LLSDSerialize::toJSON(response)); * @endcode */ - bool sendMessage(const std::string& message); + bool sendMessage(const std::string& message) const; + bool sendMessage(const boost::json::value& json) const; + bool sendMessage(const LLSD& data) const; /** * @brief Close the WebSocket connection gracefully @@ -260,6 +264,11 @@ public: virtual void onConnectionClosed(const WSConnection::ptr_t& connection) { } bool isRunning() const; + size_t getConnectionCount() const + { + LLMutexLock lock(&mConnectionMutex); + return mConnections.size(); + } void broadcastMessage(const std::string& message); protected: @@ -294,7 +303,7 @@ public: std::string mServerName; std::unique_ptr<Server_impl> mImpl; connection_map_t mConnections; - LLMutex mConnectionMutex; + mutable LLMutex mConnectionMutex; // Threading support std::thread mServerThread; ///< Thread running the ASIO event loop diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index c727d5ae57..d1527ef578 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -551,6 +551,7 @@ set(viewer_SOURCE_FILES llsceneview.cpp llscreenchannel.cpp llscripteditor.cpp + llscripteditorws.cpp llscriptfloater.cpp llscrollingpanelparam.cpp llscrollingpanelparambase.cpp @@ -1216,6 +1217,7 @@ set(viewer_HEADER_FILES llsceneview.h llscreenchannel.h llscripteditor.h + llscripteditorws.h llscriptfloater.h llscriptruntimeperms.h llscrollingpanelparam.h diff --git a/indra/newview/llpreviewscript.cpp b/indra/newview/llpreviewscript.cpp index 69f5812853..9b2a149212 100644 --- a/indra/newview/llpreviewscript.cpp +++ b/indra/newview/llpreviewscript.cpp @@ -65,22 +65,12 @@ #include "llviewerobject.h" #include "llviewerobjectlist.h" #include "llviewerregion.h" -#include "llkeyboard.h" -#include "llscrollcontainer.h" -#include "llcheckboxctrl.h" #include "llscripteditor.h" -#include "llselectmgr.h" -#include "lltooldraganddrop.h" -#include "llscrolllistctrl.h" #include "lltextbox.h" -#include "llslider.h" -#include "lldir.h" -#include "llcombobox.h" #include "llviewerstats.h" #include "llviewerwindow.h" #include "lluictrlfactory.h" #include "llmediactrl.h" -#include "lluictrlfactory.h" #include "lltrans.h" #include "llviewercontrol.h" #include "llappviewer.h" @@ -91,6 +81,8 @@ #include "lltoggleablemenu.h" #include "llmenubutton.h" #include "llinventoryfunctions.h" +#include "llwebsocketmgr.h" +#include "llscripteditorws.h" #include <regex> const std::string HELP_LSL_PORTAL_TOPIC = "LSL_Portal"; @@ -1129,11 +1121,13 @@ void LLScriptEdCore::openInExternalEditor() // Generate a suitable filename std::string script_name = mScriptName; - std::string forbidden_chars = "<>:\"\\/|?*"; - for (std::string::iterator c = forbidden_chars.begin(); c != forbidden_chars.end(); c++) - { - script_name.erase(std::remove(script_name.begin(), script_name.end(), *c), script_name.end()); - } + + static const std::set<char> forbidden_chars{ '<', '>', ':', '"', '\\', '/', '|', '?', '*' }; + script_name.erase( + std::remove_if(script_name.begin(), script_name.end(), [](char c) { + return forbidden_chars.contains(c); + }), script_name.end()); + std::string filename = mContainer->getTmpFileName(script_name); // Save the script to a temporary file. @@ -1151,6 +1145,28 @@ void LLScriptEdCore::openInExternalEditor() mContainer->mLiveFile = new LLLiveLSLFile(filename, boost::bind(&LLScriptEdContainer::onExternalChange, mContainer, _1)); mContainer->mLiveFile->addToEventTimer(); + // if the user has enabled websockets, create the server to talk to the external editor + { + // TODO: Get the name, port, and locality from settings + std::string server_name(LLScriptEditorWSServer::DEFAULT_SERVER_NAME); + U16 server_port(LLScriptEditorWSServer::DEFAULT_SERVER_PORT); + bool server_localhost(true); + + LLWebsocketMgr& wsmgr = LLWebsocketMgr::instance(); + LLScriptEditorWSServer::ptr_t server = + std::static_pointer_cast<LLScriptEditorWSServer>(wsmgr.findServerByName(server_name)); + + if (!server) + { + server = std::make_shared<LLScriptEditorWSServer>(server_name, server_port, server_localhost); + wsmgr.addServer(server); + wsmgr.startServer(server_name); + } + + std::string script_id_hash_str(mContainer->getUniqueHash()); + server->associateEditor(getHandle(), script_id_hash_str); + } + // Open it in external editor. { LLExternalEditor ed; @@ -1560,18 +1576,9 @@ LLScriptEdContainer::~LLScriptEdContainer() mLiveLogFile = nullptr; } -std::string LLScriptEdContainer::getTmpFileName(const std::string& script_name) +std::string LLScriptEdContainer::getTmpFileName(const std::string& script_name) const { - // Take script inventory item id (within the object inventory) - // to consideration so that it's possible to edit multiple scripts - // in the same object inventory simultaneously (STORM-781). - std::string script_id = mObjectUUID.asString() + "_" + mItemUUID.asString(); - - // Use MD5 sum to make the file name shorter and not exceed maximum path length. - char script_id_hash_str[33]; /* Flawfinder: ignore */ - LLMD5 script_id_hash((const U8 *)script_id.c_str()); - script_id_hash.hex_digest(script_id_hash_str); - + std::string script_id_hash_str(getUniqueHash()); std::string script_extension = mScriptEd->mEditor->getIsLuauLanguage() ? ".luau" : ".lsl"; if (script_name.empty()) @@ -1584,6 +1591,21 @@ std::string LLScriptEdContainer::getTmpFileName(const std::string& script_name) } } +std::string LLScriptEdContainer::getUniqueHash() const +{ + // Take script inventory item id (within the object inventory) + // to consideration so that it's possible to edit multiple scripts + // in the same object inventory simultaneously (STORM-781). + std::string script_id = mObjectUUID.asString() + "_" + mItemUUID.asString(); + + // Use MD5 sum to make the file name shorter and not exceed maximum path length. + char script_id_hash_str[33]; /* Flawfinder: ignore */ + LLMD5 script_id_hash((const U8*)script_id.c_str()); + script_id_hash.hex_digest(script_id_hash_str); + + return std::string(script_id_hash_str); +} + std::string LLScriptEdContainer::getErrorLogFileName(const std::string& script_path) { if (script_path.empty()) diff --git a/indra/newview/llpreviewscript.h b/indra/newview/llpreviewscript.h index 1b60774deb..14ec54d42b 100644 --- a/indra/newview/llpreviewscript.h +++ b/indra/newview/llpreviewscript.h @@ -219,7 +219,8 @@ public: bool handleKeyHere(KEY key, MASK mask); protected: - std::string getTmpFileName(const std::string& script_name); + std::string getTmpFileName(const std::string& script_name) const; + std::string getUniqueHash() const; std::string getErrorLogFileName(const std::string& script_path); bool onExternalChange(const std::string& filename); virtual void saveIfNeeded(bool sync = true) = 0; diff --git a/indra/newview/llscripteditorws.cpp b/indra/newview/llscripteditorws.cpp new file mode 100644 index 0000000000..503624d15a --- /dev/null +++ b/indra/newview/llscripteditorws.cpp @@ -0,0 +1,608 @@ +/** + * @file llscripteditorws.cpp + * + * $LicenseInfo:firstyear=2002&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2010, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA + * $/LicenseInfo$ + */ + +/* + * ============================================================================ + * WEBSOCKET MESSAGE FORMAT SPECIFICATION + * ============================================================================ + * + * This WebSocket implementation uses a structured JSON message format for + * communication between the Second Life viewer and external script editors. + * All messages are encoded as UTF-8 JSON strings. + * + * ## BASE MESSAGE STRUCTURE + * + * Every WebSocket message follows this mandatory structure: + * + * ```json + * { + * "command": "message_type_identifier", // REQUIRED: Command/message type + * "data": { // OPTIONAL: Command-specific payload + * // ... command-specific fields ... + * }, + * "timestamp": "2025-01-27T15:30:00Z", // OPTIONAL: ISO8601 timestamp + * "session_id": "uuid-string", // OPTIONAL: Session identifier + * "message_id": "unique-id" // OPTIONAL: For request/response correlation + * } + * ``` + * + * ## MESSAGE TYPES + * + * ### 1. SERVER-TO-CLIENT MESSAGES + * + * #### capabilities + * Sent immediately when client connects to announce server capabilities. + * ```json + * { + * "command": "capabilities", + * "data": { + * "server_name": "Second Life Script Editor WebSocket Server", + * "server_version": "1.0.0", + * "protocol_version": "1.0", + * "viewer_name": "Second Life", + * "viewer_version": "Second Life Release 6.4.26", + * "session_id": "generated-uuid", + * "timestamp": "2025-01-27T15:30:00Z", + * "capabilities": ["script_editing", "compilation", "metadata", ...], + * "supported_languages": ["lsl", "luau"], + * "features": { + * "live_compilation": true, + * "debugging": false, + * "breakpoints": false + * } + * } + * } + * ``` + * + * #### error + * Sent when message processing fails or protocol violations occur. + * ```json + * { + * "command": "error", + * "data": { + * "error_code": "PARSE_ERROR|INVALID_MESSAGE|UNKNOWN_MESSAGE_TYPE", + * "error_message": "Human-readable error description" + * } + * } + * ``` + * + * ### 2. CLIENT-TO-SERVER MESSAGES + * + * #### connect + * Client handshake message to establish connection and negotiate capabilities. + * ```json + * { + * "command": "connect", + * "data": { + * "client_name": "VS Code LSL Extension", + * "client_version": "1.2.3", + * "protocol_version": "1.0", + * "capabilities": ["script_editing", "syntax_highlighting"], + * "supported_languages": ["lsl", "luau"], + * "features": { + * "auto_completion": true, + * "live_preview": false + * }, + * "session_id": "optional-client-provided-uuid" + * } + * } + * ``` + * + * ### 3. BIDIRECTIONAL MESSAGES + * + * #### connect_ack + * Server response to client connect message. + * ```json + * { + * "command": "connect_ack", + * "data": { + * "status": "connected|rejected", + * "message": "Connection established successfully", + * "session_id": "established-session-uuid", + * "timestamp": "2025-01-27T15:30:00Z", + * "mutual_capabilities": ["script_editing", "compilation"], + * "supported_languages": ["lsl", "luau"], + * "max_script_size": 65536, + * "heartbeat_interval": 30, + * // For rejected connections: + * "error": "incompatible_protocol|missing_capabilities|connection_failed" + * } + * } + * ``` + * + * ## FUTURE MESSAGE TYPES (Planned) + * + * The following message types are referenced in the protocol design but not + * yet implemented: + * + * - `script_updated`: Editor notifies viewer of script content changes + * - `save_request`: Editor requests script save to SL servers + * - `compile_request`: Editor requests script compilation + * - `script_content`: Viewer sends full script content to editor + * - `compile_result`: Viewer sends compilation results to editor + * - `save_result`: Viewer sends save operation results to editor + * - `metadata`: Script and object metadata exchange + * - `ping`/`pong`: Connection health check messages + * - `editor_ready`: Editor initialization complete notification + * + * ## PROTOCOL RULES + * + * 1. **Encoding**: All messages must be valid UTF-8 JSON + * 2. **Required Fields**: Every message MUST have a "command" field + * 3. **Case Sensitivity**: All field names are case-sensitive + * 4. **Protocol Version**: Currently only "1.0" is supported + * 5. **Error Handling**: Invalid messages trigger "error" responses + * 6. **Connection Flow**: Client must send "connect" before other commands + * 7. **Session Management**: session_id tracks individual editor sessions + * 8. **Capability Negotiation**: Features limited to mutual capabilities + * + * ## ERROR CODES + * + * - `PARSE_ERROR`: Invalid JSON syntax + * - `PARSE_EXCEPTION`: JSON parsing threw exception + * - `INVALID_MESSAGE`: Missing required "command" field + * - `UNKNOWN_MESSAGE_TYPE`: Unrecognized command type + * - `INCOMPATIBLE_PROTOCOL`: Unsupported protocol version + * - `MISSING_CAPABILITIES`: Required capabilities not supported + * - `CONNECTION_FAILED`: General connection establishment failure + * + * ## IMPLEMENTATION NOTES + * + * - Messages are parsed using boost::json and converted to LLSD internally + * - All timestamps use ISO 8601 format in UTC timezone + * - UUIDs are generated using LLUUID::generateNewID() for session tracking + * - Maximum script size is currently limited to 65536 bytes + * - WebSocket server binds to localhost only for security by default + * - Protocol designed for extensibility with additional message types + * + * ============================================================================ + */ + + +#include "llviewerprecompiledheaders.h" +#include "llscripteditorws.h" +#include "llpreviewscript.h" +#include "llappviewer.h" +#include "lltrans.h" +#include "lldate.h" +#include "llerror.h" +#include "lluuid.h" +#include "llsdjson.h" +#include <boost/json.hpp> + +//------------------------------------------------------------------------ + +LLScriptEditorWSServer::LLScriptEditorWSServer(const std::string_view name, U16 port, bool local_only): + LLWebsocketMgr::WSServer(name, port, local_only) +{ +} + +LLWebsocketMgr::WSConnection::ptr_t LLScriptEditorWSServer::connectionFactory(WSServer::ptr_t server, LLWebsocketMgr::connection_h handle) +{ + return std::make_shared<LLScriptEditorWSConnection>(server, handle); +} + +//------------------------------------------------------------------------ +void LLScriptEditorWSServer::onConnectionOpened(const LLWebsocketMgr::WSConnection::ptr_t& connection) +{ + LL_INFOS("ScriptEditorWS") << "New script editor client connected" << LL_ENDL; + + // Build capabilities message to send to the newly connected external editor + LLSD capabilities_message; + capabilities_message["command"] = "capabilities"; + + LLSD& data = capabilities_message["data"]; + + // Server identification and version information + data["server_name"] = "Second Life Script Editor WebSocket Server"; + data["server_version"] = "1.0.0"; + data["protocol_version"] = "1.0"; + + // Viewer information + data["viewer_name"] = LLTrans::getString("APP_NAME"); + data["viewer_version"] = LLAppViewer::instance()->getSecondLifeTitle(); + + // Session information + data["session_id"] = LLUUID::generateNewID().asString(); + data["timestamp"] = LLDate::now().asString(); + + // Server capabilities - what the viewer/server supports + LLSD capabilities = LLSD::emptyArray(); + capabilities.append("script_editing"); // Basic script content editing + capabilities.append("script_synchronization"); // Real-time sync between viewer and editor + capabilities.append("compilation"); // Compile results with errors/warnings + capabilities.append("metadata"); // Script and object metadata + capabilities.append("syntax_highlighting"); // LSL/Luau syntax information + capabilities.append("error_reporting"); // Detailed error reporting + + data["capabilities"] = capabilities; + + // Language support information + LLSD languages = LLSD::emptyArray(); + languages.append("lsl"); // Linden Scripting Language + languages.append("luau"); // Luau scripting language + data["supported_languages"] = languages; + + // Feature flags + LLSD features; + features["live_compilation"] = true; + features["debugging"] = false; // Not implemented yet + features["breakpoints"] = false; // Not implemented yet + data["features"] = features; + + // Send the capabilities message to the newly connected client + if (connection->sendMessage(capabilities_message)) + { + LL_INFOS("ScriptEditorWS") << "Sent capabilities message to new client" << LL_ENDL; + } + else + { + LL_WARNS("ScriptEditorWS") << "Failed to send capabilities message to new client" << LL_ENDL; + } +} + +void LLScriptEditorWSServer::onConnectionClosed(const LLWebsocketMgr::WSConnection::ptr_t& connection) +{ + LL_INFOS("ScriptEditorWS") << "Script editor client disconnected" << LL_ENDL; + + // Remove from active connections + auto script_connection = std::dynamic_pointer_cast<LLScriptEditorWSConnection>(connection); + if (script_connection) + { + mActiveConnections.erase(script_connection); + + // Remove from any script associations + LL_INFOS("ScriptEditorWS") << "Removed connection from active connections. Total: " + << mActiveConnections.size() << LL_ENDL; + } +} + +//------------------------------------------------------------------------ +bool LLScriptEditorWSServer::associateEditor(const LLHandle<LLPanel>& editor_handle, const std::string& script_id) +{ + if (!editor_handle.isDead()) + { + mScriptEditors[script_id] = editor_handle; + return true; + } + return false; +} + +void LLScriptEditorWSServer::dissociateEditor(const std::string& script_id) +{ + mScriptEditors.erase(script_id); +} + +LLHandle<LLPanel> LLScriptEditorWSServer::findEditorForScript(const std::string& script_id) const +{ + auto it = mScriptEditors.find(script_id); + if (it != mScriptEditors.end()) + { + return it->second; + } + return LLHandle<LLPanel>(); +} + +//======================================================================== +void LLScriptEditorWSConnection::onOpen() +{ + +} + +void LLScriptEditorWSConnection::onClose() +{ +} + +void LLScriptEditorWSConnection::onMessage(const std::string& message) +{ + LL_DEBUGS("ScriptEditorWS") << "Received message: " << message << LL_ENDL; + + // Convert JSON string to LLSD + LLSD parsed_message; + try + { + boost::system::error_code ec; + boost::json::value json_value = boost::json::parse(message, ec); + + if (ec.failed()) + { + LL_WARNS("ScriptEditorWS") << "Failed to parse JSON message: " << ec.message() << LL_ENDL; + + // Send error response back to client + LLSD error_response; + error_response["command"] = "error"; + error_response["data"]["error_code"] = "PARSE_ERROR"; + error_response["data"]["error_message"] = "Invalid JSON format: " + std::string(ec.message()); + sendMessage(error_response); + return; + } + + // Convert boost::json::value to LLSD + parsed_message = LlsdFromJson(json_value); + + LL_DEBUGS("ScriptEditorWS") << "Parsed LLSD message type: " << parsed_message.type() + << ", has 'type' field: " << parsed_message.has("command") << LL_ENDL; + } + catch (const std::exception& e) + { + LL_WARNS("ScriptEditorWS") << "Exception parsing JSON message: " << e.what() << LL_ENDL; + + // Send error response back to client + LLSD error_response; + error_response["command"] = "error"; + error_response["data"]["error_code"] = "PARSE_EXCEPTION"; + error_response["data"]["error_message"] = "JSON parsing exception: " + std::string(e.what()); + sendMessage(error_response); + return; + } + + // Validate that we have a proper message structure + if (!parsed_message.has("command")) + { + LL_WARNS("ScriptEditorWS") << "Received message without 'type' field" << LL_ENDL; + + LLSD error_response; + error_response["command"] = "error"; + error_response["data"]["error_code"] = "INVALID_MESSAGE"; + error_response["data"]["error_message"] = "Message must have a 'type' field"; + sendMessage(error_response); + return; + } + + std::string message_type = parsed_message["command"].asString(); + LL_INFOS("ScriptEditorWS") << "Processing message of type: " << message_type << LL_ENDL; + + // Route message to appropriate handler based on type + if (message_type == "connect") + { + processConnectMessage(parsed_message); + } + else + { + LL_WARNS("ScriptEditorWS") << "Received unknown message type: " << message_type << LL_ENDL; + + LLSD error_response; + error_response["command"] = "error"; + error_response["data"]["error_code"] = "UNKNOWN_MESSAGE_TYPE"; + error_response["data"]["error_message"] = "Unknown message type: " + message_type; + sendMessage(error_response); + } +} + +void LLScriptEditorWSConnection::processConnectMessage(const LLSD& message) +{ + LL_INFOS("ScriptEditorWS") << "Processing connect message from client" << LL_ENDL; + + // Extract connection information from the message + LLSD data; + if (message.has("data")) + { + data = message["data"]; + } + + // Parse client information + std::string client_name; + std::string client_version; + std::string protocol_version; + LLSD client_capabilities; + LLSD supported_languages; + LLSD client_features; + + // Extract client identification + if (data.has("client_name")) + { + client_name = data["client_name"].asString(); + LL_INFOS("ScriptEditorWS") << "Client name: " << client_name << LL_ENDL; + } + + if (data.has("client_version")) + { + client_version = data["client_version"].asString(); + LL_INFOS("ScriptEditorWS") << "Client version: " << client_version << LL_ENDL; + } + + if (data.has("protocol_version")) + { + protocol_version = data["protocol_version"].asString(); + LL_INFOS("ScriptEditorWS") << "Protocol version: " << protocol_version << LL_ENDL; + } + + // Extract client capabilities + if (data.has("capabilities")) + { + client_capabilities = data["capabilities"]; + mEditorCapabilities = client_capabilities; // Store for later use + LL_INFOS("ScriptEditorWS") << "Client capabilities count: " << client_capabilities.size() << LL_ENDL; + } + + // Extract supported languages + if (data.has("supported_languages")) + { + supported_languages = data["supported_languages"]; + LL_INFOS("ScriptEditorWS") << "Supported languages count: " << supported_languages.size() << LL_ENDL; + } + + // Extract client features + if (data.has("features")) + { + client_features = data["features"]; + LL_INFOS("ScriptEditorWS") << "Client features available" << LL_ENDL; + } + + // Generate or extract editor session ID + if (data.has("session_id")) + { + mEditorId = data["session_id"].asString(); + LL_INFOS("ScriptEditorWS") << "Using client session ID: " << mEditorId << LL_ENDL; + } + else + { + // Generate a new session ID if client didn't provide one + mEditorId = LLUUID::generateNewID().asString(); + LL_INFOS("ScriptEditorWS") << "Generated session ID: " << mEditorId << LL_ENDL; + } + + // Validate protocol compatibility + bool protocol_compatible = true; + if (!protocol_version.empty()) + { + // For now, we only support protocol version "1.0" + if (protocol_version != "1.0") + { + protocol_compatible = false; + LL_WARNS("ScriptEditorWS") << "Unsupported protocol version: " << protocol_version + << ", expected: 1.0" << LL_ENDL; + } + } + + // Determine if we have feature compatibility + bool has_script_editing = false; + if (client_capabilities.isArray()) + { + for (LLSD::array_const_iterator it = client_capabilities.beginArray(); + it != client_capabilities.endArray(); ++it) + { + if (it->asString() == "script_editing") + { + has_script_editing = true; + break; + } + } + } + + // Build response message + LLSD response; + response["command"] = "connect_ack"; + + LLSD& response_data = response["data"]; + + if (protocol_compatible && has_script_editing) + { + // Successful connection + response_data["status"] = "connected"; + response_data["message"] = "Connection established successfully"; + response_data["session_id"] = mEditorId; + response_data["timestamp"] = LLDate::now().asString(); + + // Send back our supported capabilities that match the client's + LLSD mutual_capabilities = LLSD::emptyArray(); + + // Check which capabilities we both support + if (client_capabilities.isArray()) + { + // Our server capabilities (from onConnectionOpened) + std::set<std::string> server_caps = { + "script_editing", "script_synchronization", "compilation", + "metadata", "syntax_highlighting", "error_reporting" + }; + + for (LLSD::array_const_iterator it = client_capabilities.beginArray(); + it != client_capabilities.endArray(); ++it) + { + std::string cap = it->asString(); + if (server_caps.count(cap) > 0) + { + mutual_capabilities.append(cap); + } + } + } + + response_data["mutual_capabilities"] = mutual_capabilities; + + // Send supported languages intersection + LLSD mutual_languages = LLSD::emptyArray(); + if (supported_languages.isArray()) + { + std::set<std::string> server_languages = {"lsl", "luau"}; + + for (LLSD::array_const_iterator it = supported_languages.beginArray(); + it != supported_languages.endArray(); ++it) + { + std::string lang = it->asString(); + if (server_languages.count(lang) > 0) + { + mutual_languages.append(lang); + } + } + } + else + { + // Default to all our supported languages if client didn't specify + mutual_languages.append("lsl"); + mutual_languages.append("luau"); + } + + response_data["supported_languages"] = mutual_languages; + + // Connection limits and constraints + response_data["max_script_size"] = 65536; + response_data["heartbeat_interval"] = 30; + + LL_INFOS("ScriptEditorWS") << "Successfully connected client: " << client_name + << " v" << client_version + << " with " << mutual_capabilities.size() << " mutual capabilities" << LL_ENDL; + } + else + { + // Connection failed + response_data["status"] = "rejected"; + + if (!protocol_compatible) + { + response_data["error"] = "incompatible_protocol"; + response_data["message"] = "Unsupported protocol version: " + protocol_version; + } + else if (!has_script_editing) + { + response_data["error"] = "missing_capabilities"; + response_data["message"] = "Client must support 'script_editing' capability"; + } + else + { + response_data["error"] = "connection_failed"; + response_data["message"] = "Connection failed for unknown reason"; + } + + LL_WARNS("ScriptEditorWS") << "Rejected connection from client: " << client_name + << " - " << response_data["message"].asString() << LL_ENDL; + } + + // Send the response back to the client + if (sendMessage(response)) + { + LL_INFOS("ScriptEditorWS") << "Sent connect acknowledgment to client" << LL_ENDL; + } + else + { + LL_WARNS("ScriptEditorWS") << "Failed to send connect acknowledgment to client" << LL_ENDL; + } + + // If connection was successful, we could also send any initial state or configuration + if (response_data["status"].asString() == "connected") + { + // TODO: Send initial script list, active editors, or other relevant state + // Example: sendScriptList(), sendActiveEditors(), etc. + } +} diff --git a/indra/newview/llscripteditorws.h b/indra/newview/llscripteditorws.h new file mode 100644 index 0000000000..73efb1a65f --- /dev/null +++ b/indra/newview/llscripteditorws.h @@ -0,0 +1,185 @@ +/** + * @file llscripteditorws.h + * @brief WebSocket server and connection classes for external script editor integration + * + * $LicenseInfo:firstyear=2025&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2025, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "llwebsocketmgr.h" +#include "llsd.h" +#include "lluuid.h" +#include "llhandle.h" +#include "lltimer.h" + +#include <memory> +#include <string> +#include <map> +#include <set> + +// Forward declarations +class LLLiveLSLEditor; +class LLScriptEdCore; + +/** + * @class LLScriptEditorWSConnection + * @brief WebSocket connection specialized for external script editor communication + * + * This class handles WebSocket communication between the Second Life viewer + * and external script editors. It manages script content synchronization, + * compilation status updates, and editor metadata exchange. + * + * ## Message Protocol + * + * The connection uses JSON messages with the following structure: + * - `type`: Message type identifier + * - `data`: Message payload (varies by type) + * - `timestamp`: Message timestamp for ordering + * - `id`: Optional message ID for request/response correlation + * + * ### Supported Message Types: + * + * #### From Editor to Viewer: + * - `script_updated`: Script content has been modified + * - `save_request`: Request to save script to SL servers + * - `compile_request`: Request to compile script + * - `editor_ready`: Editor initialization complete + * - `ping`: Connection health check + * + * #### From Viewer to Editor: + * - `script_content`: Full script content + * - `compile_result`: Compilation success/failure with errors + * - `save_result`: Save operation result + * - `metadata`: Script and object metadata + * - `pong`: Response to ping + */ +class LLScriptEditorWSConnection : public LLWebsocketMgr::WSConnection +{ +public: + + LLScriptEditorWSConnection(const LLWebsocketMgr::WSServer::ptr_t server, + const LLWebsocketMgr::connection_h& handle): + LLWebsocketMgr::WSConnection(server, handle), + mMessageSequence(0) + { } + + ~LLScriptEditorWSConnection() override = default; + + + // Connection lifecycle overrides + void onOpen() override; + void onClose() override; + void onMessage(const std::string& message) override; + +private: + /** + * @brief Handle connect/connection messages from editor + * @param message Parsed LLSD message + */ + void processConnectMessage(const LLSD& message); + + std::string mEditorId; ///< Unique identifier for this editor session + LLSD mEditorCapabilities; ///< Editor capabilities metadata + U32 mMessageSequence; ///< Message sequence counter + std::string mScriptId; ///< Unique identifier for the script being edited +}; + +/** + * @class LLScriptEditorWSServer + * @brief WebSocket server for external script editor integration + * + * This server manages WebSocket connections from external script editors, + * providing a bridge between the Second Life viewer's script editing + * functionality and external development tools. + * + * ## Architecture + * + * The server acts as a communication hub between: + * - LLLiveLSLEditor instances (in-world script editing) + * - External script editors (VS Code, Atom, Sublime Text, etc.) + * - Script compilation and save services + * + * ## Usage + * + * @code + * // Create and start the server + * auto server = std::make_shared<LLScriptEditorWSServer>("script_editor_server", 8080); + * LLWebsocketMgr::getInstance()->addServer(server); + * LLWebsocketMgr::getInstance()->startServer("script_editor_server"); + * + * // Associate with an LSL editor + * server->associateEditor(editor_handle, script_id); + * @endcode + * + * ## Security Considerations + * + * - Server binds to localhost only by default for security + * - Editor authentication via connection handshake + * - Script content encryption for sensitive projects + * - Rate limiting to prevent abuse + */ +class LLScriptEditorWSServer : public LLWebsocketMgr::WSServer +{ +public: + static constexpr char const* DEFAULT_SERVER_NAME = "script_editor_server"; + static constexpr U16 DEFAULT_SERVER_PORT = 9020; + + using ptr_t = std::shared_ptr<LLScriptEditorWSServer>; + + LLScriptEditorWSServer(const std::string_view name, U16 port, bool local_only = true); + + virtual ~LLScriptEditorWSServer() = default; + + // Server lifecycle callbacks + void onConnectionOpened(const LLWebsocketMgr::WSConnection::ptr_t& connection) override; + void onConnectionClosed(const LLWebsocketMgr::WSConnection::ptr_t& connection) override; + + bool associateEditor(const LLHandle<LLPanel>& editor_handle, const std::string& script_id); + void dissociateEditor(const std::string& script_id); + + LLHandle<LLPanel> findEditorForScript(const std::string& script_id) const; + std::shared_ptr<LLScriptEditorWSConnection> findConnectionForScript(const std::string& script_id); + + /** + * @brief Get list of active script editing sessions + * @return Set of script IDs currently being edited + */ + std::set<std::string> getActiveScripts() const; + +protected: + LLWebsocketMgr::WSConnection::ptr_t connectionFactory(WSServer::ptr_t server, LLWebsocketMgr::connection_h handle) override; + +private: + using map_id_to_editor_t = std::unordered_map<std::string, LLHandle<LLPanel> >; + + map_id_to_editor_t mScriptEditors; + + std::set<std::shared_ptr<LLScriptEditorWSConnection>> mActiveConnections; + + /** + * @brief Connection timeout management + */ + LLTimer mCleanupTimer; + static constexpr F32 CLEANUP_INTERVAL = 60.0f; // seconds + static constexpr F32 CONNECTION_TIMEOUT = 300.0f; // 5 minutes +}; |
