/** * @file llscripteditorws.cpp * @brief JSON-RPC 2.0 WebSocket server implementation for external script editor integration * * For a full description of the JSON-RPC protocol and all supported methods, * see doc/external-editor-json-rpc.md in the repository root. * * $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$ */ #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 "llversioninfo.h" #include "llagent.h" #include "llregex.h" #include "llviewerobject.h" #include "llviewerobjectlist.h" #include "llchat.h" //======================================================================== LLScriptEditorWSServer::LLScriptEditorWSServer(const std::string& name, U16 port, bool local_only) : LLJSONRPCServer(name, port, local_only) { LL_INFOS("ScriptEditorWS") << "Created JSON-RPC script editor server: " << name << " on port " << port << LL_ENDL; } LLScriptEditorWSServer::ptr_t LLScriptEditorWSServer::getServer() { if (!LLWebsocketMgr::instanceExists()) { return nullptr; } LLWebsocketMgr& wsmgr = LLWebsocketMgr::instance(); return std::static_pointer_cast( wsmgr.findServerByName(LLScriptEditorWSServer::DEFAULT_SERVER_NAME)); } LLWebsocketMgr::WSConnection::ptr_t LLScriptEditorWSServer::connectionFactory(LLWebsocketMgr::WSServer::ptr_t server, LLWebsocketMgr::connection_h handle) { auto connection = std::make_shared(server, handle); mActiveConnections[connection->getConnectionID()] = connection; // Call setupConnectionMethods to register any global methods setupConnectionMethods(connection); return connection; } void LLScriptEditorWSServer::onStarted() { LLSyntaxDefCache& syntax_id_mgr = LLSyntaxDefCache::instance(); wptr_t that(std::static_pointer_cast(shared_from_this())); mLastSyntaxId = syntax_id_mgr.getSyntaxID(); mLanguageChangeSignal = syntax_id_mgr.addSyntaxIDCallback( [that]() { auto server = that.lock(); if (server && server->isRunning()) { server->broadcastLanguageChange(); } }); } void LLScriptEditorWSServer::onStopped() { mLanguageChangeSignal.disconnect(); mLastSyntaxId.setNull(); } void LLScriptEditorWSServer::onConnectionOpened(const LLWebsocketMgr::WSConnection::ptr_t& connection) { // Call parent class to handle JSON-RPC setup and standard methods LLJSONRPCServer::onConnectionOpened(connection); LL_INFOS("ScriptEditorWS") << "New script editor client connected via JSON-RPC" << LL_ENDL; } void LLScriptEditorWSServer::onConnectionClosed(const LLWebsocketMgr::WSConnection::ptr_t& connection) { // Call parent class to handle JSON-RPC cleanup LLJSONRPCServer::onConnectionClosed(connection); LL_INFOS("ScriptEditorWS") << "Script editor client disconnected" << LL_ENDL; // Remove from active connections auto script_connection = std::dynamic_pointer_cast(connection); if (script_connection) { U32 connection_id = script_connection->getConnectionID(); unsubscribeConnection(connection_id); mActiveConnections.erase(connection_id); LL_DEBUGS("ScriptEditorWS") << "Removed connection from active connections. Total: " << mActiveConnections.size() << LL_ENDL; // TODO: When connections reach 0, stop the server after a timeout. } } bool LLScriptEditorWSServer::subscribeScriptEditor(const LLUUID& object_id, const LLUUID& item_id, std::string_view script_name, const LLHandle& editor_handle, const std::string& script_id) { if (!editor_handle.isDead()) { auto it = mSubscriptions.find(script_id); if (it == mSubscriptions.end()) { // Don't re-add if already subscribed mSubscriptions.emplace(script_id, LLScriptEditorWSServer::EditorSubscription(object_id, item_id, script_name, editor_handle)); return false; } else { // Update existing subscription with new editor handle it->second.mEditorHandle = editor_handle; } return true; } return false; } void LLScriptEditorWSServer::unsubscribeEditor(const std::string &script_id) { auto it = mSubscriptions.find(script_id); if (it != mSubscriptions.end()) { S32 connection_id = it->second.mConnectionID; auto connection = it->second.mConnection.lock(); mSubscriptions.erase(it); ptrdiff_t count = std::count_if(mSubscriptions.begin(), mSubscriptions.end(), [connection_id](const auto& pair) { return pair.second.mConnectionID == connection_id; }); if (connection && !count) { // We have removed the last subscription, close the connection LL_DEBUGS("ScriptEditorWS") << "Closing connection ID " << connection_id << " as last subscription was removed" << LL_ENDL; connection->sendDisconnect(LLScriptEditorWSConnection::REASON_EDITOR_CLOSED, "Editor closed"); } } } void LLScriptEditorWSServer::unsubscribeConnection(U32 connection_id) { for (auto it = mSubscriptions.begin(); it != mSubscriptions.end(); ++it) { if (it->second.mConnectionID == connection_id) { LL_DEBUGS("ScriptEditorWS") << "Unsubscribing script " << it->first << " from connection ID " << connection_id << LL_ENDL; it->second.mConnectionID = 0; it->second.mConnection.reset(); } } } LLScriptEditorWSServer::SubscriptionError_t LLScriptEditorWSServer::updateScriptSubscription(const std::string &script_id, U32 connection_id) { auto it = mSubscriptions.find(script_id); if (it != mSubscriptions.end()) { if (it->second.mEditorHandle.isDead()) { unsubscribeEditor(script_id); return SUBSCRIPTION_INVALID_EDITOR; } auto con_it = mActiveConnections.find(connection_id); if (con_it == mActiveConnections.end()) { return SUBSCRIPTION_INTERNAL_ERROR; } if ((it->second.mConnectionID != 0) && !it->second.mConnection.expired() && it->second.mConnection.lock()->isConnected()) { LL_WARNS("ScriptEditorWS") << "Script " << script_id << " is already subscribed on connection ID " << it->second.mConnectionID << ", cannot subscribe again on connection ID " << connection_id << LL_ENDL; // In the future we may want to support multiple connections per script. // That would imply it was open in multiple editors. return SUBSCRIPTION_ALREADY_SUBSCRIBED; } it->second.mConnectionID = connection_id; it->second.mConnection = con_it->second; return SUBSCRIPTION_SUCCESS; } return SUBSCRIPTION_INVALID_SUBSCRIPTION; } LLHandle LLScriptEditorWSServer::findEditorForScript(const std::string& script_id) const { auto it = mSubscriptions.find(script_id); if (it != mSubscriptions.end()) { return it->second.mEditorHandle; } return LLHandle(); } std::set LLScriptEditorWSServer::getActiveScripts() const { std::set active_scripts; for (const auto& [script_id, subinfo] : mSubscriptions) { if (!subinfo.mEditorHandle.isDead()) { active_scripts.insert(script_id); } } return active_scripts; } void LLScriptEditorWSServer::setupConnectionMethods(LLJSONRPCConnection::ptr_t connection) { // Call parent class to register global JSON-RPC methods LLJSONRPCServer::setupConnectionMethods(connection); // Cast to our specific connection type to access script editor functionality auto script_connection = std::dynamic_pointer_cast(connection); if (script_connection) { LL_DEBUGS("ScriptEditorWS") << "Setting up script editor connection methods" << LL_ENDL; wptr_t that(std::static_pointer_cast(shared_from_this())); U32 connection_id = script_connection->getConnectionID(); script_connection->registerMethod("language.syntax.id", [that](const std::string&, const LLSD&, const LLSD&) -> LLSD { auto server = that.lock(); if (server) { return server->handleLanguageIdRequest(); } return LLSD(); }); script_connection->registerMethod("language.syntax", [that](const std::string&, const LLSD&, const LLSD& params) { auto server = that.lock(); if (server) { return server->handleSyntaxRequest(params); } return LLSD(); }); script_connection->registerMethod("language.syntax.cache", [that](const std::string&, const LLSD&, const LLSD& params) { auto server = that.lock(); if (server) { return server->handleSyntaxCacheRequest(); } return LLSD(); }); script_connection->registerMethod("language.syntax.get", [that](const std::string&, const LLSD&, const LLSD& params) { auto server = that.lock(); if (server) { return server->handleSyntaxCacheFileRequest(params); } return LLSD(); }); script_connection->registerMethod("script.subscribe", [that, connection_id](const std::string&, const LLSD&, const LLSD& params) -> LLSD { auto server = that.lock(); if (server) { return server->handleScriptSubscribe(connection_id, params); } return LLSD(); }); script_connection->registerMethod("script.unsubscribe", [](const std::string&, const LLSD&, const LLSD& params) -> LLSD { // this is a notification, no response expected return LLSD(); }); script_connection->registerMethod("script.list", [that](const std::string&, const LLSD&, const LLSD& params) -> LLSD { auto server = that.lock(); if (server) { return server->handleFileWatcherFileListRequest(); } return LLSD(); }); // script_connection->registerMethod("language.syntax", ) } } void LLScriptEditorWSServer::broadcastLanguageChange() { LLUUID syntax_id = LLSyntaxDefCache::instance().getSyntaxID(); if (syntax_id != mLastSyntaxId) { mLastSyntaxId = syntax_id; LLSD params; params["id"] = syntax_id; if (isRunning()) { broadcastNotification("language.syntax.change", params); } } } LLSD LLScriptEditorWSServer::handleLanguageIdRequest() const { LLSD response; response["id"] = mLastSyntaxId; return response; } LLSD LLScriptEditorWSServer::handleSyntaxRequest(const LLSD& params) const { LLSD response(LLSD::emptyMap()); std::string category = params["kind"].asString(); if (category.empty()) { response["error"] = "No syntax category specified"; response["success"] = false; return response; } response["id"] = mLastSyntaxId; if (category == "defs.lua") { response["defs"] = LLSyntaxDefCache::instance().getLuaKeywords(); response["success"] = response["defs"].isDefined(); } else if (category == "defs.lsl") { response["defs"] = LLSyntaxDefCache::instance().getLSLKeywords(); response["success"] = response["defs"].isDefined(); } else { response["error"] = "Unknown syntax category requested"; response["success"] = false; } return response; } LLSD LLScriptEditorWSServer::handleSyntaxCacheRequest() const { LLSD response; // Add array of cached syntax definition files LLSD syntax_files = LLSD::emptyArray(); for (const auto& name : LLSyntaxDefCache::instance().getCacheFileNames()) { syntax_files.append(name); } response["files"] = syntax_files; response["success"] = true; return response; } LLSD LLScriptEditorWSServer::handleSyntaxCacheFileRequest(const LLSD& params) const { std::string filename = params["filename"].asString(); bool as_json = params["as_json"].asBoolean(); LLSyntaxDefCache& cache = LLSyntaxDefCache::instance(); LLSD response; if (filename.empty()) { response["error"] = "No filename specified"; response["success"] = false; return response; } if (!cache.hasCacheFile(filename)) { response["error"] = "Requested syntax cache file not found"; response["success"] = false; return response; } bool success = false; if (as_json) { LLSD file_content = cache.loadCacheFileAsLLSD(filename); if (file_content.isDefined()) { response["content"] = file_content; success = true; } else { response["error"] = "Failed to load and format syntax cache file."; } } else { std::string content = cache.loadCacheFile(filename); if (!content.empty()) { response["content"] = content; success = true; } else { response["error"] = "Failed to load syntax cache file"; } } response["success"] = success; return response; } LLSD LLScriptEditorWSServer::handleScriptSubscribe(U32 connection_id, const LLSD& params) { LLSD response(LLSD::emptyMap()); std::string script_id = params["script_id"].asString(); std::string script_name = params["script_name"].asString(); std::string language = params["script_language"].asString(); SubscriptionError_t result = updateScriptSubscription(script_id, connection_id); response["script_id"] = script_id; response["success"] = (result == SUBSCRIPTION_SUCCESS); response["status"] = result; LL_WARNS_IF(result != SUBSCRIPTION_SUCCESS, "ScriptEditorWS") << "Script connect request for script " << script_id << " failed with status " << result << LL_ENDL; switch (result) { case SUBSCRIPTION_SUCCESS: response["message"] = "OK"; break; case SUBSCRIPTION_INVALID_EDITOR: response["message"] = "Invalid editor handle"; break; case SUBSCRIPTION_INVALID_SUBSCRIPTION: response["message"] = "No subscription found for script"; break; case SUBSCRIPTION_ALREADY_SUBSCRIBED: response["message"] = "Script already subscribed"; break; case SUBSCRIPTION_INTERNAL_ERROR: response["message"] = "Internal server error"; break; } if (result == SUBSCRIPTION_SUCCESS) { auto it = mSubscriptions.find(script_id); if (it != mSubscriptions.end()) { LLViewerObject* object = gObjectList.findObject((*it).second.mObjectID); response["object_id"] = (*it).second.mObjectID; //response["object_name"] = object ? object->getName() : "Unknown"; response["item_id"] = (*it).second.mItemID; } } return response; } LLSD LLScriptEditorWSServer::handleScriptUnsubscribe(U32 connection_id, const LLSD& params) { std::string script_id = params["script_id"].asString(); auto it = mSubscriptions.find(script_id); if (it != mSubscriptions.end() && (it->second.mConnectionID == connection_id)) { unsubscribeEditor(script_id); } return LLSD(); } LLSD LLScriptEditorWSServer::handleFileWatcherFileListRequest() const { LLSD response; response["temp_dir"] = LLFile::tmpdir(); // Add array of script_id's from active scripts LLSD script_ids_array = LLSD::emptyArray(); for (const auto& [script_id, subinfo] : mSubscriptions) { script_ids_array.append(script_id); } response["script_ids"] = script_ids_array; response["success"] = true; return response; } void LLScriptEditorWSServer::notifyScript(const std::string& script_id, const std::string &method, const LLSD& message) const { auto it = mSubscriptions.find(script_id); if (it != mSubscriptions.end()) { auto connection = it->second.mConnection.lock(); if (connection) { connection->notify(method, message); } } } void LLScriptEditorWSServer::sendUnsubscribeScriptEditor(const std::string& script_id) { LLSD params; params["script_id"] = script_id; notifyScript(script_id, "script.unsubscribe", params); } void LLScriptEditorWSServer::sendCompileResults(const std::string &script_id, const LLSD &results) const { LLHandle editor_handle = findEditorForScript(script_id); if (editor_handle.isDead()) { return; } LLScriptEdContainer* editor = dynamic_cast(editor_handle.get()); if (!editor) { return; } LLScriptEdCore* core = editor->getScriptEdCore(); bool is_lua = core && (core->isLuauLanguage()); LLSD params; params["script_id"] = script_id; params["success"] = results["compiled"].asBoolean(); params["running"] = results["is_running"].asBoolean(); if (results.has("errors")) { params["errors"] = LLSD::emptyArray(); if (is_lua) { // lua errors: ":line: message", line is 1-based const static boost::regex lua_err_regex(R"(^[^:]*:(\d+): (.+)$)"); for (const auto& err : llsd::inArray(results["errors"])) { boost::smatch match; LLSD err_entry; err_entry["column"] = 0; // TODO: Lua compiler does not provide column info err_entry["level"] = "ERROR"; if (boost::regex_match(err.asString(), match, lua_err_regex)) { S32 line_number = std::stoi(match[1].str()); std::string message = match[2].str(); err_entry["row"] = line_number; err_entry["message"] = message; } else { err_entry["row"] = 0; err_entry["message"] = err.asString(); } params["errors"].append(err_entry); } } else { // lsl errors: "(line, column) : SEVERITY : message", line and column are 0-based static const boost::regex lsl_err_regex(R"(\((\d+), (\d+)\) : ([^:]+) : (.+))"); for (const auto& err : llsd::inArray(results["errors"])) { boost::smatch match; LLSD err_entry; if (boost::regex_match(err.asString(), match, lsl_err_regex)) { S32 line_number = std::stoi(match[1].str()); S32 col_number = std::stoi(match[2].str()); std::string severity = match[3].str(); std::string message = match[4].str(); err_entry["row"] = line_number + 1; err_entry["column"] = col_number + 1; err_entry["level"] = severity; err_entry["message"] = message; err_entry["format"] = "lsl"; } else { err_entry["row"] = 0; err_entry["column"] = 0; err_entry["level"] = "ERROR"; err_entry["message"] = err.asString(); err_entry["format"] = "lsl"; } params["errors"].append(err_entry); } } } notifyScript(script_id, "script.compiled", params); } void LLScriptEditorWSServer::forwardChatToIDE(const LLChat& chat_msg) const { auto it = std::find_if(mSubscriptions.begin(), mSubscriptions.end(), [&chat_msg](const auto& pair) { return (pair.second.mObjectID == chat_msg.mFromID); }); if (it == mSubscriptions.end()) { // Not a script we are tracking return; } bool is_error = false; std::string error_message; std::string object_name; std::string script_name; S32 line_number = 0; // We have at least one script from this object, we will forward the message to the IDE // but first we need to see if it is a runtime error std::vector lines = LLStringUtil::getTokens(chat_msg.mText, "\n"); // If this is a runtime error, the first line will look like: " [script: