summaryrefslogtreecommitdiff
path: root/indra
diff options
context:
space:
mode:
authorRider Linden <rider@lindenlab.com>2026-07-22 17:41:55 -0700
committerGitHub <noreply@github.com>2026-07-22 17:41:55 -0700
commita6a2f98070cd55fcaadafc237bd0ffba9d466655 (patch)
tree82f5444312786242e062de65ce6891213b032051 /indra
parent8a22869dfad731f8cd9f4164a7a2b57cd70af51c (diff)
Publish object inventories to external editor. (#5834)
* Adding tight integration flag for vscode and open code through a URL. * [WIP] Publish objects and their contents from the viewer into VS code. * [WIP] Still very much a work in progress, supports most of the core operations publish, get, write, delete, and create. Still quite a few bugs to work out. * [WIP] Object publishing checkpoint. * [checkpoint] Script tight integration with vscode, object publishing. * Number of fixed issue. * A few redundancy and performance fixes. * Some cosmetics. * I like "Explore" better than "Publish" * Object renaming, luau inventory icon, runstate, restart. * Some clean up around permissions and possible nullptr deref. * Code review feedback.
Diffstat (limited to 'indra')
-rw-r--r--indra/llcommon/llprofilercategories.h19
-rw-r--r--indra/llcorehttp/lljsonrpcws.cpp389
-rw-r--r--indra/llcorehttp/lljsonrpcws.h95
-rw-r--r--indra/llcorehttp/llwebsocketmgr.cpp36
-rw-r--r--indra/llcorehttp/llwebsocketmgr.h5
-rw-r--r--indra/llinventory/llinventorytype.h2
-rw-r--r--indra/newview/app_settings/settings.xml11
-rw-r--r--indra/newview/llfloaterimnearbychathandler.cpp5
-rw-r--r--indra/newview/llfloaterscripting.cpp28
-rw-r--r--indra/newview/llfloaterscripting.h4
-rw-r--r--indra/newview/llinventorybridge.cpp9
-rw-r--r--indra/newview/llinventorybridge.h1
-rw-r--r--indra/newview/llinventoryicon.cpp14
-rw-r--r--indra/newview/llinventoryicon.h1
-rw-r--r--indra/newview/llpanelcontents.cpp223
-rw-r--r--indra/newview/llpanelcontents.h13
-rw-r--r--indra/newview/llpanelobjectinventory.cpp12
-rw-r--r--indra/newview/llpreviewscript.cpp105
-rw-r--r--indra/newview/llscripteditorws.cpp2158
-rw-r--r--indra/newview/llscripteditorws.h163
-rw-r--r--indra/newview/llselectmgr.cpp11
-rw-r--r--indra/newview/llviewerinventory.cpp10
-rw-r--r--indra/newview/llviewerinventory.h8
-rw-r--r--indra/newview/llviewermenu.cpp46
-rw-r--r--indra/newview/llviewerobject.cpp133
-rw-r--r--indra/newview/llviewerobject.h12
-rw-r--r--indra/newview/llviewerregion.cpp1
-rw-r--r--indra/newview/skins/default/textures/icons/Inv_Script_Luau.pngbin0 -> 807 bytes
-rw-r--r--indra/newview/skins/default/textures/textures.xml1
-rw-r--r--indra/newview/skins/default/xui/en/floater_scripting_settings.xml15
-rw-r--r--indra/newview/skins/default/xui/en/floater_tools.xml51
-rw-r--r--indra/newview/skins/default/xui/en/menu_viewer.xml21
-rw-r--r--indra/newview/skins/default/xui/en/strings.xml2
33 files changed, 3155 insertions, 449 deletions
diff --git a/indra/llcommon/llprofilercategories.h b/indra/llcommon/llprofilercategories.h
index 261fdf14b7..4fc1d7f159 100644
--- a/indra/llcommon/llprofilercategories.h
+++ b/indra/llcommon/llprofilercategories.h
@@ -70,6 +70,8 @@
#define LL_PROFILER_CATEGORY_ENABLE_WIN32 1
#define LL_PROFILER_CATEGORY_ENABLE_GLTF 1
#define LL_PROFILER_CATEGORY_ENABLE_VOICE 1
+#define LL_PROFILER_CATEGORY_ENABLE_WEBSOCKET 1
+#define LL_PROFILER_CATEGORY_ENABLE_SCRIPTDEV 1
#if LL_PROFILER_CATEGORY_ENABLE_APP
#define LL_PROFILE_ZONE_NAMED_CATEGORY_APP LL_PROFILE_ZONE_NAMED
@@ -302,5 +304,22 @@
#define LL_PROFILE_ZONE_SCOPED_CATEGORY_VOICE
#endif
+#if LL_PROFILER_CATEGORY_ENABLE_WEBSOCKET
+ #define LL_PROFILE_ZONE_NAMED_CATEGORY_WEBSOCKET LL_PROFILE_ZONE_NAMED
+ #define LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET LL_PROFILE_ZONE_SCOPED
+#else
+ #define LL_PROFILE_ZONE_NAMED_CATEGORY_WEBSOCKET(name)
+ #define LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET
+#endif
+
+#if LL_PROFILER_CATEGORY_ENABLE_SCRIPTDEV
+ #define LL_PROFILE_ZONE_NAMED_CATEGORY_SCRIPTDEV LL_PROFILE_ZONE_NAMED
+ #define LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV LL_PROFILE_ZONE_SCOPED
+#else
+ #define LL_PROFILE_ZONE_NAMED_CATEGORY_SCRIPTDEV(name)
+ #define LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV
+#endif
+
+
#endif // LL_PROFILER_CATEGORIES_H
diff --git a/indra/llcorehttp/lljsonrpcws.cpp b/indra/llcorehttp/lljsonrpcws.cpp
index 93e38a8397..3a0d3d1f26 100644
--- a/indra/llcorehttp/lljsonrpcws.cpp
+++ b/indra/llcorehttp/lljsonrpcws.cpp
@@ -30,6 +30,10 @@
#include "llerror.h"
#include "llsdjson.h"
#include "lldate.h"
+#include "llcoros.h"
+#include "llmainthreadtask.h"
+#include "lleventtimer.h"
+#include "lltimer.h"
#include <boost/json.hpp>
@@ -40,29 +44,57 @@
void LLJSONRPCConnection::onOpen()
{
LL_INFOS("JSONRPC") << "JSON-RPC connection opened" << LL_ENDL;
+
+ // Start the recurring timeout sweep timer on the main thread. The timer
+ // is canceled in onClose() before the connection can be destroyed, so
+ // capturing `this` is safe. Keep a weak_ptr so we can safely test
+ // whether the timer instance still exists at cancellation time.
+ LLEventTimer* timer = LLEventTimer::run_every(TIMEOUT_SWEEP_INTERVAL,
+ [this]() { sweepTimeouts(); });
+ mTimeoutTimer = timer->getWeak();
}
void LLJSONRPCConnection::onClose()
{
+ // Cancel the sweep timer if it is still alive. LLEventTimer's instance
+ // tracker keeps a shared_ptr with a no-op deleter, so raw `delete` is
+ // the documented cancellation idiom (see lleventtimer.h).
+ if (auto timer = mTimeoutTimer.lock())
+ {
+ delete timer.get();
+ }
+ mTimeoutTimer.reset();
+
+ // Move the pending-request map out under the lock so we can invoke the
+ // callbacks without holding it (callbacks may themselves call into this
+ // connection).
+ std::unordered_map<std::string, ResponseCallback> pending;
+ {
+ LLMutexLock lock(&mMutex);
+ pending.swap(mPendingRequests);
+ // Deadlines correspond to entries in mPendingRequests; drop them.
+ std::priority_queue<PendingDeadline> empty;
+ mPendingDeadlines.swap(empty);
+ }
+
LL_INFOS("JSONRPC") << "JSON-RPC connection closed, clearing "
- << mPendingRequests.size() << " pending requests" << LL_ENDL;
+ << pending.size() << " pending requests" << LL_ENDL;
- // Cancel all pending requests
- for (auto& [id, callback] : mPendingRequests)
+ for (auto& [id, callback] : pending)
{
if (callback)
{
LLSD error;
- error["code"] = RPCError::CONNECTION_CLOSED; // Use named constant instead of magic number
+ error["code"] = RPCError::CONNECTION_CLOSED;
error["message"] = "Connection closed";
callback(LLSD(), error);
}
}
- mPendingRequests.clear();
}
void LLJSONRPCConnection::onMessage(const std::string& message)
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
LL_DEBUGS("JSONRPC") << "Received JSON-RPC message: " << message << LL_ENDL;
try
@@ -84,24 +116,18 @@ void LLJSONRPCConnection::onMessage(const std::string& message)
// Handle batch vs single message
if (message_obj.isArray())
{
- // Batch request
- if (message_obj.size() == 0)
- {
- sendError(LLSD(), InvalidRequest("Empty batch"));
- return;
- }
-
- // Process each message in the batch
- for (S32 i = 0; i < message_obj.size(); ++i)
- {
- processMessage(message_obj[i]);
- }
- }
- else
- {
- // Single message
- processMessage(message_obj);
+ // JSON-RPC 2.0 batch requests are intentionally not supported.
+ // No known client (including the sl-vscode-plugin) sends batches,
+ // and a spec-compliant implementation would require accumulating
+ // responses across sync + async handlers before shipping a single
+ // array frame. If a real use case appears, implement per
+ // JSON-RPC 2.0 §6.
+ sendError(LLSD(), InvalidRequest("Batch requests are not supported"));
+ return;
}
+
+ // Single message
+ processMessage(message_obj);
}
catch (const std::exception& e)
{
@@ -112,6 +138,7 @@ void LLJSONRPCConnection::onMessage(const std::string& message)
void LLJSONRPCConnection::processMessage(const LLSD& message_obj)
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
try
{
// Determine if this is a request, notification, or response
@@ -145,6 +172,7 @@ void LLJSONRPCConnection::processMessage(const LLSD& message_obj)
void LLJSONRPCConnection::processRequest(const LLSD& request)
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
std::string method = request["method"].asString();
LLSD params = request.has("params") ? request["params"] : LLSD();
LLSD id = request.has("id") ? request["id"] : LLSD();
@@ -153,9 +181,93 @@ void LLJSONRPCConnection::processRequest(const LLSD& request)
LL_DEBUGS("JSONRPC") << "Processing " << (is_notification ? "notification" : "request")
<< " for method: " << method << LL_ENDL;
- // Find method handler
- auto it = mMethodHandlers.find(method);
- if (it == mMethodHandlers.end())
+ // Resolve the handler under the mutex, then invoke it unlocked.
+ MethodHandler handler;
+ bool is_async = false;
+ {
+ LLMutexLock lock(&mMutex);
+ auto async_it = mAsyncMethodHandlers.find(method);
+ if (async_it != mAsyncMethodHandlers.end())
+ {
+ handler = async_it->second;
+ is_async = true;
+ }
+ else
+ {
+ auto sync_it = mMethodHandlers.find(method);
+ if (sync_it != mMethodHandlers.end())
+ {
+ handler = sync_it->second;
+ }
+ }
+ }
+
+ if (is_async)
+ {
+ // Async handler — launched as a coroutine, response sent by the lambda.
+ if (is_notification)
+ {
+ LL_WARNS("JSONRPC") << "Async method " << method
+ << " called as notification; ignoring" << LL_ENDL;
+ return;
+ }
+ ptr_t conn = std::static_pointer_cast<LLJSONRPCConnection>(getSelfPtr());
+ if (!conn)
+ {
+ LL_WARNS("JSONRPC") << "Connection expired before async method " << method
+ << " could be launched" << LL_ENDL;
+ return;
+ }
+ LLMainThreadTask::dispatch(
+ [handler, method, id, params, conn]()
+ {
+ LLCoros::instance().launch(
+ "JSONRPC::" + method,
+ [handler, method, id, params, conn]()
+ {
+ try
+ {
+ LLSD result = handler(method, id, params);
+ if (conn->isConnected())
+ {
+ conn->sendResponse(id, result);
+ }
+ else
+ {
+ LL_WARNS("JSONRPC") << "Connection closed before async method "
+ << method << " could send response" << LL_ENDL;
+ }
+ }
+ catch (const RPCError& e)
+ {
+ if (conn->isConnected())
+ {
+ conn->sendError(id, e);
+ }
+ else
+ {
+ LL_WARNS("JSONRPC") << "Connection closed before async method "
+ << method << " could send error" << LL_ENDL;
+ }
+ }
+ catch (const std::exception& e)
+ {
+ if (conn->isConnected())
+ {
+ conn->sendError(id, InternalError(e.what()));
+ }
+ else
+ {
+ LL_WARNS("JSONRPC") << "Connection closed before async method "
+ << method << " could send error" << LL_ENDL;
+ }
+ }
+ });
+ });
+ return;
+ }
+
+ if (!handler)
{
if (!is_notification)
{
@@ -166,8 +278,7 @@ void LLJSONRPCConnection::processRequest(const LLSD& request)
try
{
- // Call the method handler with method name, ID, and parameters
- LLSD result = it->second(method, id, params);
+ LLSD result = handler(method, id, params);
if (!is_notification)
{
@@ -202,6 +313,7 @@ void LLJSONRPCConnection::processRequest(const LLSD& request)
void LLJSONRPCConnection::processResponse(const LLSD& response)
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
if (!response.has("id"))
{
LL_WARNS("JSONRPC") << "Response missing id field" << LL_ENDL;
@@ -209,20 +321,23 @@ void LLJSONRPCConnection::processResponse(const LLSD& response)
}
std::string id = response["id"].asString();
- auto it = mPendingRequests.find(id);
- if (it == mPendingRequests.end())
+ ResponseCallback callback;
{
- LL_WARNS("JSONRPC") << "Received response for unknown request id: " << id << LL_ENDL;
- return;
+ LLMutexLock lock(&mMutex);
+ auto it = mPendingRequests.find(id);
+ if (it == mPendingRequests.end())
+ {
+ LL_WARNS("JSONRPC") << "Received response for unknown request id: " << id << LL_ENDL;
+ return;
+ }
+ callback = std::move(it->second);
+ mPendingRequests.erase(it);
}
- ResponseCallback callback = it->second;
- mPendingRequests.erase(it);
-
if (callback)
{
LLSD result = response.has("result") ? response["result"] : LLSD();
- LLSD error = response.has("error") ? response["error"] : LLSD();
+ LLSD error = response.has("error") ? response["error"] : LLSD();
callback(result, error);
}
@@ -294,21 +409,60 @@ bool LLJSONRPCConnection::validateMessage(const LLSD& message, bool is_request)
if (!error.isMap())
{
LL_WARNS("JSONRPC") << "Error must be an object" << LL_ENDL;
+ return false;
}
if (!error.has("code") || !error.has("message"))
{
LL_WARNS("JSONRPC") << "Error must have code and message" << LL_ENDL;
+ return false;
}
}
}
return true;
}
+void LLJSONRPCConnection::sweepTimeouts()
+{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
+ // Pop expired deadlines and collect their callbacks. Tombstones (entries
+ // whose request already completed) are silently discarded.
+ std::vector<std::pair<std::string, ResponseCallback>> expired;
+ const F64 now = LLTimer::getTotalSeconds();
+ {
+ LLMutexLock lock(&mMutex);
+ while (!mPendingDeadlines.empty() && mPendingDeadlines.top().mDeadline <= now)
+ {
+ std::string id = mPendingDeadlines.top().mId;
+ mPendingDeadlines.pop();
+ auto it = mPendingRequests.find(id);
+ if (it != mPendingRequests.end())
+ {
+ expired.emplace_back(std::move(id), std::move(it->second));
+ mPendingRequests.erase(it);
+ }
+ }
+ }
+
+ for (auto& [id, callback] : expired)
+ {
+ LL_WARNS("JSONRPC") << "Request " << id << " timed out after "
+ << REQUEST_TIMEOUT_SECONDS << " seconds" << LL_ENDL;
+ if (callback)
+ {
+ LLSD error;
+ error["code"] = RPCError::REQUEST_TIMEOUT;
+ error["message"] = "Request timed out";
+ callback(LLSD(), error);
+ }
+ }
+}
+
LLSD LLJSONRPCConnection::generateId()
{
- // Server-wide atomic counter for efficient unique ID generation
- // Start from 1000 to avoid conflicts with any manual test IDs
- static std::atomic<U64> sRequestIdCounter{1000};
+ // Server-wide atomic counter for efficient unique ID generation.
+ // Start above zero to avoid conflicts with any manual test IDs.
+ static constexpr U64 REQUEST_ID_START = 1000;
+ static std::atomic<U64> sRequestIdCounter{REQUEST_ID_START};
// Generate server-unique sequential ID
U64 id = sRequestIdCounter.fetch_add(1);
@@ -317,43 +471,87 @@ LLSD LLJSONRPCConnection::generateId()
void LLJSONRPCConnection::registerMethod(const std::string& method, MethodHandler handler)
{
- mMethodHandlers[method] = handler;
+ {
+ LLMutexLock lock(&mMutex);
+ mMethodHandlers[method] = std::move(handler);
+ }
LL_DEBUGS("JSONRPC") << "Registered method: " << method << LL_ENDL;
}
+void LLJSONRPCConnection::registerAsyncMethod(const std::string& method, MethodHandler handler)
+{
+ {
+ LLMutexLock lock(&mMutex);
+ mAsyncMethodHandlers[method] = std::move(handler);
+ }
+ LL_DEBUGS("JSONRPC") << "Registered async method: " << method << LL_ENDL;
+}
+
void LLJSONRPCConnection::unregisterMethod(const std::string& method)
{
- mMethodHandlers.erase(method);
+ {
+ LLMutexLock lock(&mMutex);
+ mMethodHandlers.erase(method);
+ mAsyncMethodHandlers.erase(method);
+ }
LL_DEBUGS("JSONRPC") << "Unregistered method: " << method << LL_ENDL;
}
-LLSD LLJSONRPCConnection::call(const std::string& method, const LLSD& params, ResponseCallback callback)
+LLSD LLJSONRPCConnection::makeEnvelope(const LLSD& id,
+ const std::string& method,
+ const LLSD& params,
+ const LLSD& result,
+ const LLSD& error)
{
- LLSD request;
- request["jsonrpc"] = "2.0";
- request["method"] = method;
-
- if (!params.isUndefined())
+ LLSD env;
+ env["jsonrpc"] = "2.0";
+ // Notifications (requests without an id) are the only case that omits id.
+ if (!(id.isUndefined() && !method.empty()))
+ {
+ env["id"] = id;
+ }
+ if (!method.empty())
+ {
+ env["method"] = method;
+ }
+ if (params.isDefined())
+ {
+ env["params"] = params;
+ }
+ if (result.isDefined())
{
- request["params"] = params;
+ env["result"] = result;
}
+ if (error.isDefined())
+ {
+ env["error"] = error;
+ }
+ return env;
+}
+LLSD LLJSONRPCConnection::call(const std::string& method, const LLSD& params, ResponseCallback callback)
+{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
LLSD id = generateId();
- request["id"] = id;
+ LLSD request = makeEnvelope(id, method, params, LLSD(), LLSD());
+ const std::string id_str = id.asString();
- // Store callback if provided
+ // Store callback if provided. Fire-and-forget calls (no callback) are
+ // not tracked for timeouts since there is nobody to deliver the error to.
if (callback)
{
- mPendingRequests[id.asString()] = callback;
+ LLMutexLock lock(&mMutex);
+ mPendingRequests[id_str] = std::move(callback);
+ mPendingDeadlines.push({ LLTimer::getTotalSeconds() + REQUEST_TIMEOUT_SECONDS, id_str });
}
// Send the request
if (!sendMessage(LlsdToJson(request)))
{
// Remove from pending if send failed
- if (callback)
{
- mPendingRequests.erase(id.asString());
+ LLMutexLock lock(&mMutex);
+ mPendingRequests.erase(id_str);
}
LL_WARNS("JSONRPC") << "Failed to send request" << LL_ENDL;
return LLSD();
@@ -365,16 +563,8 @@ LLSD LLJSONRPCConnection::call(const std::string& method, const LLSD& params, Re
bool LLJSONRPCConnection::notify(const std::string& method, const LLSD& params)
{
- LLSD notification;
- notification["jsonrpc"] = "2.0";
- notification["method"] = method;
-
- if (!params.isUndefined())
- {
- notification["params"] = params;
- }
-
- // Notifications don't have an id
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
+ LLSD notification = makeEnvelope(LLSD(), method, params, LLSD(), LLSD());
if (!sendMessage(LlsdToJson(notification)))
{
@@ -388,10 +578,8 @@ bool LLJSONRPCConnection::notify(const std::string& method, const LLSD& params)
bool LLJSONRPCConnection::sendResponse(const LLSD& id, const LLSD& result)
{
- LLSD response;
- response["jsonrpc"] = "2.0";
- response["result"] = result;
- response["id"] = id;
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
+ LLSD response = makeEnvelope(id, std::string(), LLSD(), result, LLSD());
if (!sendMessage(LlsdToJson(response)))
{
@@ -404,9 +592,7 @@ bool LLJSONRPCConnection::sendResponse(const LLSD& id, const LLSD& result)
bool LLJSONRPCConnection::sendError(const LLSD& id, const RPCError& error)
{
- LLSD response;
- response["jsonrpc"] = "2.0";
-
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
LLSD error_obj;
error_obj["code"] = error.getCode();
error_obj["message"] = error.what();
@@ -416,8 +602,8 @@ bool LLJSONRPCConnection::sendError(const LLSD& id, const RPCError& error)
error_obj["data"] = error.getData();
}
- response["error"] = error_obj;
- response["id"] = id.isUndefined() ? LLSD() : id; // null for parse errors
+ // Responses always include id; an undefined id serializes as null (used for parse errors).
+ LLSD response = makeEnvelope(id, std::string(), LLSD(), LLSD(), error_obj);
if (!sendMessage(LlsdToJson(response)))
{
@@ -513,6 +699,22 @@ void LLJSONRPCServer::setupConnectionMethods(LLJSONRPCConnection::ptr_t connecti
{
connection->registerMethod(method, handler);
}
+
+ // Register session.ping handler for connection health monitoring
+ connection->registerMethod("session.ping",
+ [](const std::string&, const LLSD&, const LLSD& params) -> LLSD
+ {
+ LLSD result;
+ // Echo back the original timestamp
+ if (params.has("timestamp"))
+ {
+ result["timestamp"] = params["timestamp"];
+ }
+ // Add server's current time in milliseconds
+ result["server_time"] = static_cast<LLSD::Integer>(
+ LLDate::now().secondsSinceEpoch() * 1000.0);
+ return result;
+ });
}
void LLJSONRPCServer::registerGlobalMethod(const std::string& method, MethodHandler handler)
@@ -558,54 +760,21 @@ LLSD LLJSONRPCServer::getMethodList() const
void LLJSONRPCServer::broadcastNotification(const std::string& method, const LLSD& params)
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
// Use custom broadcast logic since we need to call notify() on each JSON-RPC connection
// We can't use the base broadcastMessage() because we need structured JSON-RPC messages
// Create the notification message
- LLSD notification;
- notification["jsonrpc"] = "2.0";
- notification["method"] = method;
- if (!params.isUndefined())
- {
- notification["params"] = params;
- }
+ LLSD notification = LLJSONRPCConnection::makeEnvelope(LLSD(), method, params, LLSD(), LLSD());
// Use the base class broadcast functionality
broadcastMessage(boost::json::serialize(LlsdToJson(notification)));
- mTotalNotificationsSent += getConnectionCount();
+ // Cache the count: getConnectionCount() walks a locked container in the base.
+ size_t count = getConnectionCount();
+ mTotalNotificationsSent += count;
LL_DEBUGS("JSONRPC") << "Broadcast notification: " << method
- << " to " << getConnectionCount() << " clients" << LL_ENDL;
-}
-
-void LLJSONRPCServer::broadcastCall(const std::string& method, const LLSD& params,
- BatchResponseCallback callback)
-{
- if (callback)
- {
- LL_WARNS("JSONRPC") << "Broadcast call response callbacks not yet implemented" << LL_ENDL;
- }
-
- // Create the request message with a server-unique ID
- LLSD request;
- request["jsonrpc"] = "2.0";
- request["method"] = method;
-
- // Use the same ID generation as connections for consistency
- static std::atomic<U64> sBroadcastIdCounter{10000000}; // Start at 10M to clearly distinguish from regular requests
- U64 id = sBroadcastIdCounter.fetch_add(1);
- request["id"] = LLSD(llformat("broadcast_%llu", id));
-
- if (!params.isUndefined())
- {
- request["params"] = params;
- }
-
- // Use the base class broadcast functionality
- broadcastMessage(boost::json::serialize(LlsdToJson(request)));
-
- LL_DEBUGS("JSONRPC") << "Broadcast call: " << method
- << " to " << getConnectionCount() << " clients" << LL_ENDL;
+ << " to " << count << " clients" << LL_ENDL;
}
LLSD LLJSONRPCServer::getServerStats() const
diff --git a/indra/llcorehttp/lljsonrpcws.h b/indra/llcorehttp/lljsonrpcws.h
index bd9939aa33..cd71473a47 100644
--- a/indra/llcorehttp/lljsonrpcws.h
+++ b/indra/llcorehttp/lljsonrpcws.h
@@ -33,6 +33,9 @@
#include <functional>
#include <unordered_map>
#include <memory>
+#include <queue>
+
+class LLEventTimer;
/**
* @class LLJSONRPCConnection
@@ -218,12 +221,6 @@ public:
: RPCError(SERVICE_UNAVAILABLE, details) {}
};
- class MessageTooLargeError : public RPCError {
- public:
- MessageTooLargeError(const std::string& details = "Message exceeds maximum size")
- : RPCError(MESSAGE_TOO_LARGE, details) {}
- };
-
class InvalidSessionError : public RPCError {
public:
InvalidSessionError(const std::string& details = "Session expired or invalid")
@@ -235,7 +232,7 @@ public:
const LLWebsocketMgr::connection_h& handle)
: LLWebsocketMgr::WSConnection(server, handle) {}
- virtual ~LLJSONRPCConnection() = default;
+ ~LLJSONRPCConnection() override = default;
// WebSocket connection lifecycle
void onOpen() override;
@@ -246,10 +243,32 @@ public:
* @brief Register a method handler
* @param method The method name to register
* @param handler The function to call when this method is invoked
+ *
+ * @warning Sync handlers execute on the WebSocket I/O thread. They must
+ * only touch state that is either internal to this connection
+ * (protected by the connection's mutex) or otherwise thread-safe.
+ * Do NOT read or write viewer main-thread-only state (e.g.,
+ * gAgent, gObjectList, LLSelectMgr, LLFloaterReg, gSavedSettings,
+ * LLInventoryModel, or any LLViewerObject) from a sync handler;
+ * register with registerAsyncMethod() instead, which dispatches
+ * to the main thread inside a coroutine.
*/
void registerMethod(const std::string& method, MethodHandler handler);
/**
+ * @brief Register an async method handler, executed in a coroutine
+ *
+ * Unlike registerMethod(), the handler runs inside an LLCoros coroutine
+ * and may use llcoro::suspendUntilEventOn* to wait for async results.
+ * The handler returns its result normally; the framework sends the
+ * JSON-RPC response automatically when the coroutine returns.
+ *
+ * @param method The method name to register
+ * @param handler The coroutine-safe function to call
+ */
+ void registerAsyncMethod(const std::string& method, MethodHandler handler);
+
+ /**
* @brief Unregister a method handler
* @param method The method name to unregister
*/
@@ -336,9 +355,52 @@ protected:
*/
LLSD generateId();
+public:
+ /**
+ * @brief Build a JSON-RPC 2.0 envelope.
+ *
+ * Stamps "jsonrpc" = "2.0" and includes only the fields that are set:
+ * - @a method is included when non-empty.
+ * - @a params, @a result, @a error are included when defined.
+ * - @a id is included unless it is undefined and @a method is non-empty
+ * (i.e. notifications omit id; responses keep id, serializing an
+ * undefined id as JSON null per the JSON-RPC spec).
+ */
+ static LLSD makeEnvelope(const LLSD& id,
+ const std::string& method,
+ const LLSD& params,
+ const LLSD& result,
+ const LLSD& error);
+
private:
+ // Guards the three maps below. Handlers/callbacks are copied out from
+ // under the lock and then invoked without it held, to avoid re-entrancy
+ // and to keep the critical section short.
+ mutable LLMutex mMutex;
std::unordered_map<std::string, MethodHandler> mMethodHandlers;
+ std::unordered_map<std::string, MethodHandler> mAsyncMethodHandlers;
std::unordered_map<std::string, ResponseCallback> mPendingRequests;
+
+ // Per-request timeout tracking. mPendingDeadlines is a min-heap of
+ // (deadline, request_id) ordered by deadline; entries whose request has
+ // already been answered become tombstones (skipped when they reach the
+ // top). A single recurring timer per connection sweeps the heap.
+ struct PendingDeadline
+ {
+ F64 mDeadline; // absolute time in seconds (LLTimer::getTotalSeconds)
+ std::string mId;
+ // std::priority_queue is a max-heap; invert to get min-heap by deadline.
+ bool operator<(const PendingDeadline& rhs) const { return mDeadline > rhs.mDeadline; }
+ };
+ std::priority_queue<PendingDeadline> mPendingDeadlines;
+ std::weak_ptr<LLEventTimer> mTimeoutTimer;
+
+ static constexpr F64 REQUEST_TIMEOUT_SECONDS = 120.0;
+ static constexpr F32 TIMEOUT_SWEEP_INTERVAL = 1.0f;
+
+ /// Invoked by the sweep timer; fires the timeout callback for any
+ /// request whose deadline has passed. Safe to call from the main thread.
+ void sweepTimeouts();
};
/**
@@ -376,13 +438,6 @@ private:
* @code
* // Broadcast notification to all connected clients
* server->broadcastNotification("serverAlert", LLSD("Server will restart in 5 minutes"));
- *
- * // Call a method on all clients and collect responses
- * server->broadcastCall("getClientStatus", LLSD(), [](const LLSD& responses) {
- * for (const auto& response : llsd::inArray(responses)) {
- * LL_INFOS() << "Client status: " << response << LL_ENDL;
- * }
- * });
* @endcode
*/
class LLJSONRPCServer : public LLWebsocketMgr::WSServer
@@ -391,10 +446,9 @@ public:
using ptr_t = std::shared_ptr<LLJSONRPCServer>;
using MethodHandler = LLJSONRPCConnection::MethodHandler;
using ResponseCallback = LLJSONRPCConnection::ResponseCallback;
- using BatchResponseCallback = std::function<void(const LLSD& responses)>;
LLJSONRPCServer(const std::string& name, U16 port, bool local_only = true);
- virtual ~LLJSONRPCServer() = default;
+ ~LLJSONRPCServer() override = default;
// Server lifecycle callbacks
void onConnectionOpened(const LLWebsocketMgr::WSConnection::ptr_t& connection) override;
@@ -427,15 +481,6 @@ public:
void broadcastNotification(const std::string& method, const LLSD& params = LLSD());
/**
- * @brief Call a method on all connected clients
- * @param method The method name
- * @param params The parameters to pass
- * @param callback Callback to receive aggregated responses
- */
- void broadcastCall(const std::string& method, const LLSD& params = LLSD(),
- BatchResponseCallback callback = nullptr);
-
- /**
* @brief Get server statistics
* @return Statistics object with connection count, method count, etc.
*/
diff --git a/indra/llcorehttp/llwebsocketmgr.cpp b/indra/llcorehttp/llwebsocketmgr.cpp
index ea87d1e07e..be1ab54efd 100644
--- a/indra/llcorehttp/llwebsocketmgr.cpp
+++ b/indra/llcorehttp/llwebsocketmgr.cpp
@@ -65,6 +65,7 @@ void LLWebsocketMgr::cleanupSingleton()
void LLWebsocketMgr::update()
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
std::vector<WSServer::ptr_t> stops;
for (auto &[name, server] : mServers)
@@ -255,6 +256,7 @@ struct Server_impl
// Run controlled event loop with periodic stop flag checking
while (!mOwner->mShouldStop && !mServer.stopped())
{
+ LL_PROFILE_ZONE_NAMED_CATEGORY_WEBSOCKET("ws server run_for");
// Process events for up to 100ms, then check the stop flag
std::chrono::milliseconds timeout(100);
std::size_t handlers_run = mServer.get_io_service().run_for(timeout);
@@ -314,6 +316,7 @@ struct Server_impl
*/
void onOpen(websocketpp::connection_hdl hdl) const
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
LL_ERRS_IF(!mOwner, "WebSocket") << "mOwner should never be null. If it is, something is very wrong!" << LL_ENDL;
mOwner->handleOpenConnection(hdl);
@@ -325,6 +328,7 @@ struct Server_impl
*/
void onClose(websocketpp::connection_hdl hdl) const
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
LL_ERRS_IF(!mOwner, "WebSocket") << "mOwner should never be null" << LL_ENDL;
mOwner->handleCloseConnection(hdl);
}
@@ -342,6 +346,7 @@ struct Server_impl
*/
void onMessage(websocketpp::connection_hdl hdl, Server_t::message_ptr msg) const
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
LL_ERRS_IF(!mOwner, "WebSocket") << "mOwner should never be null" << LL_ENDL;
LLWebsocketMgr::WSConnection::ptr_t connection = mOwner->getConnection(hdl);
if (!connection)
@@ -441,6 +446,9 @@ void LLWebsocketMgr::WSServer::stop()
mShouldStop = true;
+ // Send close frames to all connected clients before stopping the ASIO loop
+ closeAllConnections(1001, "Server shutting down");
+
// Stop the websocket server (this will cause the controlled run loop to exit)
mImpl->stop();
} // Release the lock here
@@ -463,6 +471,7 @@ bool LLWebsocketMgr::WSServer::isRunning() const
void LLWebsocketMgr::WSServer::broadcastMessage(const std::string& message)
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
LL_ERRS_IF(!mImpl, "WebSocket") << "WebSocket server " << mServerName << " implementation is null !" << LL_ENDL;
LLMutexLock lock(&mConnectionMutex);
for (const auto& [handle, conn] : mConnections)
@@ -473,6 +482,7 @@ void LLWebsocketMgr::WSServer::broadcastMessage(const std::string& message)
bool LLWebsocketMgr::WSServer::sendMessageTo(const connection_h& handle, const std::string& message)
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
LL_ERRS_IF(!mImpl, "WebSocket") << "WebSocket server " << mServerName << " implementation is null !" << LL_ENDL;
websocketpp::lib::error_code ec;
mImpl->mServer.send(handle, message, websocketpp::frame::opcode::text, ec);
@@ -546,6 +556,7 @@ LLWebsocketMgr::connection_state_t LLWebsocketMgr::WSServer::getConnectionState(
void LLWebsocketMgr::WSServer::handleOpenConnection(const connection_h& handle)
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
WSConnection::ptr_t connection;
size_t size(0);
{
@@ -582,6 +593,7 @@ void LLWebsocketMgr::WSServer::handleOpenConnection(const connection_h& handle)
void LLWebsocketMgr::WSServer::handleCloseConnection(const connection_h& handle)
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
size_t size(0);
WSConnection::ptr_t connection;
{
@@ -608,6 +620,7 @@ void LLWebsocketMgr::WSServer::handleCloseConnection(const connection_h& handle)
void LLWebsocketMgr::WSServer::handleMessage(const connection_h& handle, const std::string& message)
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
WSConnection::ptr_t connection = getConnection(handle);
if (connection)
{
@@ -672,3 +685,26 @@ bool LLWebsocketMgr::WSConnection::isConnected() const
}
return server->getConnectionState(mConnectionHandle) == connection_open;
}
+
+LLWebsocketMgr::WSConnection::ptr_t LLWebsocketMgr::WSConnection::getSelfPtr()
+{
+ auto server = mOwningServer.lock();
+ if (!server) return nullptr;
+ return server->getConnection(mConnectionHandle);
+}
+
+void LLWebsocketMgr::WSServer::closeAllConnections(U16 code, const std::string& reason)
+{
+ std::vector<connection_h> handles;
+ {
+ LLMutexLock lock(&mConnectionMutex);
+ for (const auto& [handle, conn] : mConnections)
+ {
+ handles.push_back(handle);
+ }
+ }
+ for (const auto& handle : handles)
+ {
+ closeConnection(handle, code, reason);
+ }
+}
diff --git a/indra/llcorehttp/llwebsocketmgr.h b/indra/llcorehttp/llwebsocketmgr.h
index 4165b3cecc..2c335307e3 100644
--- a/indra/llcorehttp/llwebsocketmgr.h
+++ b/indra/llcorehttp/llwebsocketmgr.h
@@ -152,6 +152,10 @@ public:
bool isConnected() const;
protected:
+ /// Returns a shared_ptr to this connection, retrieved from the owning server.
+ /// Valid only while the connection is open and registered with the server.
+ ptr_t getSelfPtr();
+
connection_h mConnectionHandle;
std::weak_ptr<WSServer> mOwningServer; // Back-reference to the server this connection belongs to
};
@@ -260,6 +264,7 @@ public:
* This method is thread-safe and can be called from any thread.
*/
bool closeConnection(const connection_h& handle, U16 code = 1000, const std::string& reason = std::string());
+ void closeAllConnections(U16 code = 1001, const std::string& reason = "Server shutting down");
private:
using connection_map_t = std::map<connection_h, WSConnection::ptr_t, std::owner_less<connection_h> >;
diff --git a/indra/llinventory/llinventorytype.h b/indra/llinventory/llinventorytype.h
index 0627b8df3c..ce8bedb68d 100644
--- a/indra/llinventory/llinventorytype.h
+++ b/indra/llinventory/llinventorytype.h
@@ -123,6 +123,8 @@ public:
ICONNAME_MATERIAL,
+ ICONNAME_SCRIPT_LUAU,
+
ICONNAME_INVALID,
ICONNAME_UNKNOWN,
ICONNAME_COUNT,
diff --git a/indra/newview/app_settings/settings.xml b/indra/newview/app_settings/settings.xml
index 20a88084e9..c99b48eae8 100644
--- a/indra/newview/app_settings/settings.xml
+++ b/indra/newview/app_settings/settings.xml
@@ -14198,6 +14198,17 @@
<key>Value</key>
<integer>1</integer>
</map>
+ <key>ExternalEditorTightIntegration</key>
+ <map>
+ <key>Comment</key>
+ <string>When true, Edit in External Editor launches VS Code via the code CLI with a vscode:// URI instead of using the configured external editor.</string>
+ <key>Persist</key>
+ <integer>1</integer>
+ <key>Type</key>
+ <string>Boolean</string>
+ <key>Value</key>
+ <integer>0</integer>
+ </map>
<key>ExternalWebsocketForwardDebug</key>
<map>
<key>Comment</key>
diff --git a/indra/newview/llfloaterimnearbychathandler.cpp b/indra/newview/llfloaterimnearbychathandler.cpp
index b107188417..3f1fe50b73 100644
--- a/indra/newview/llfloaterimnearbychathandler.cpp
+++ b/indra/newview/llfloaterimnearbychathandler.cpp
@@ -528,7 +528,7 @@ void LLFloaterIMNearbyChatHandler::processChat(const LLChat& chat_msg,
if (!gSavedSettings.getBOOL("ShowScriptErrors"))
return;
- if (gSavedSettings.getBOOL("ExternalWebsocketSyncEnable") && gSavedSettings.getBOOL("ExternalWebsocketForwardDebug"))
+ if (LLScriptEditorWSServer::isEnabled() && gSavedSettings.getBOOL("ExternalWebsocketForwardDebug"))
{
LLScriptEditorWSServer::ptr_t server = LLScriptEditorWSServer::getServer();
if (server)
@@ -556,8 +556,7 @@ void LLFloaterIMNearbyChatHandler::processChat(const LLChat& chat_msg,
return;
}
}
- else if ((chat_msg.mChatType == CHAT_TYPE_OWNER) &&
- gSavedSettings.getBOOL("ExternalWebsocketSyncEnable") &&
+ else if ((chat_msg.mChatType == CHAT_TYPE_OWNER) && LLScriptEditorWSServer::isEnabled() &&
gSavedSettings.getBOOL("ExternalWebsocketForwardDebug"))
{
LLScriptEditorWSServer::ptr_t server = LLScriptEditorWSServer::getServer();
diff --git a/indra/newview/llfloaterscripting.cpp b/indra/newview/llfloaterscripting.cpp
index 0719ced58d..959d4eaeb4 100644
--- a/indra/newview/llfloaterscripting.cpp
+++ b/indra/newview/llfloaterscripting.cpp
@@ -42,6 +42,7 @@
#include "lleventcoro.h"
#include "llviewermenufile.h"
#include "llappviewer.h"
+#include "llscripteditorws.h"
namespace
{
@@ -82,10 +83,35 @@ LLFloaterScripting::LLFloaterScripting(const LLSD& seed)
bool LLFloaterScripting::postBuild()
{
- refresh();
+ // Subscribe to tight integration changes
+ mTightIntegrationConnection = gSavedSettings.getControl("ExternalEditorTightIntegration")->getSignal()->connect(
+ boost::bind(&LLFloaterScripting::onTightIntegrationChanged, this));
+
+ // Apply initial state
+ onTightIntegrationChanged();
+
return true;
}
+LLFloaterScripting::~LLFloaterScripting()
+{
+ mTightIntegrationConnection.disconnect();
+}
+
+void LLFloaterScripting::onTightIntegrationChanged()
+{
+ bool tight = gSavedSettings.getBOOL("ExternalEditorTightIntegration");
+
+ // Force websocket on when tight integration is enabled
+ if (LLScriptEditorWSServer::isTightIntegration())
+ {
+ gSavedSettings.setBOOL("ExternalWebsocketSyncEnable", true);
+ }
+
+ // Disable websocket checkbox when tight integration is on
+ getChild<LLCheckBoxCtrl>("websocket_sync_enable")->setEnabled(!tight);
+}
+
void LLFloaterScripting::onClickClose()
{
closeFloater();
diff --git a/indra/newview/llfloaterscripting.h b/indra/newview/llfloaterscripting.h
index ca7bd3e091..cf6b43e88d 100644
--- a/indra/newview/llfloaterscripting.h
+++ b/indra/newview/llfloaterscripting.h
@@ -44,6 +44,10 @@ public:
private:
LLFloaterScripting(const LLSD& seed);
+ ~LLFloaterScripting() override;
+ void onTightIntegrationChanged();
+
+ boost::signals2::connection mTightIntegrationConnection;
};
diff --git a/indra/newview/llinventorybridge.cpp b/indra/newview/llinventorybridge.cpp
index c8ea14a11e..adeb65bf40 100644
--- a/indra/newview/llinventorybridge.cpp
+++ b/indra/newview/llinventorybridge.cpp
@@ -7488,6 +7488,15 @@ bool LLObjectBridge::renameItem(const std::string& new_name)
// | LLLSLTextBridge |
// +=================================================+
+LLUIImagePtr LLLSLTextBridge::getIcon() const
+{
+ // Pass the item's flags so the script subtype (e.g. SST_LUA) is honored
+ // and the correct icon (Inv_Script vs Inv_Script_Luau) is selected.
+ LLInventoryItem* item = getItem();
+ U32 misc_flag = item ? item->getFlags() : 0;
+ return LLInventoryIcon::getIcon(LLAssetType::AT_LSL_TEXT, LLInventoryType::IT_LSL, misc_flag, false);
+}
+
void LLLSLTextBridge::openItem()
{
LLViewerInventoryItem* item = getItem();
diff --git a/indra/newview/llinventorybridge.h b/indra/newview/llinventorybridge.h
index decb2c0528..c62f383160 100644
--- a/indra/newview/llinventorybridge.h
+++ b/indra/newview/llinventorybridge.h
@@ -572,6 +572,7 @@ public:
LLFolderView* root,
const LLUUID& uuid ) :
LLItemBridge(inventory, root, uuid) {}
+ virtual LLUIImagePtr getIcon() const;
virtual void openItem();
};
diff --git a/indra/newview/llinventoryicon.cpp b/indra/newview/llinventoryicon.cpp
index 94b8c4bebf..64e142212d 100644
--- a/indra/newview/llinventoryicon.cpp
+++ b/indra/newview/llinventoryicon.cpp
@@ -101,6 +101,8 @@ LLIconDictionary::LLIconDictionary()
addEntry(LLInventoryType::ICONNAME_MATERIAL, new IconEntry("Inv_Material"));
+ addEntry(LLInventoryType::ICONNAME_SCRIPT_LUAU, new IconEntry("Inv_Script_Luau"));
+
addEntry(LLInventoryType::ICONNAME_INVALID, new IconEntry("Inv_Invalid"));
addEntry(LLInventoryType::ICONNAME_UNKNOWN, new IconEntry("Inv_Unknown"));
@@ -150,7 +152,7 @@ const std::string& LLInventoryIcon::getIconName(LLAssetType::EType asset_type,
case LLAssetType::AT_SCRIPT:
case LLAssetType::AT_LSL_TEXT:
case LLAssetType::AT_LSL_BYTECODE:
- idx = LLInventoryType::ICONNAME_SCRIPT;
+ idx = assignScriptIcon(misc_flag);
break;
case LLAssetType::AT_CLOTHING:
case LLAssetType::AT_BODYPART:
@@ -209,3 +211,13 @@ LLInventoryType::EIconName LLInventoryIcon::assignSettingsIcon(U32 misc_flag)
LLSettingsType::type_e settings_type = LLSettingsType::fromInventoryFlags(misc_flag);
return LLSettingsType::getIconName(settings_type);
}
+
+LLInventoryType::EIconName LLInventoryIcon::assignScriptIcon(U32 misc_flag)
+{
+ U8 subtype = misc_flag & LLInventoryItemFlags::II_FLAGS_SUBTYPE_MASK;
+ if (subtype == SST_LUA)
+ {
+ return LLInventoryType::ICONNAME_SCRIPT_LUAU;
+ }
+ return LLInventoryType::ICONNAME_SCRIPT;
+}
diff --git a/indra/newview/llinventoryicon.h b/indra/newview/llinventoryicon.h
index 32e2d8b29d..3d2ecb43f2 100644
--- a/indra/newview/llinventoryicon.h
+++ b/indra/newview/llinventoryicon.h
@@ -49,6 +49,7 @@ public:
protected:
static LLInventoryType::EIconName assignWearableIcon(U32 misc_flag);
static LLInventoryType::EIconName assignSettingsIcon(U32 misc_flag);
+ static LLInventoryType::EIconName assignScriptIcon(U32 misc_flag);
};
#endif // LL_LLINVENTORYICON_H
diff --git a/indra/newview/llpanelcontents.cpp b/indra/newview/llpanelcontents.cpp
index 2e4dc88217..c0b96ced2f 100644
--- a/indra/newview/llpanelcontents.cpp
+++ b/indra/newview/llpanelcontents.cpp
@@ -31,6 +31,7 @@
// linden library includes
#include "llerror.h"
+#include "llcombobox.h"
#include "llfiltereditor.h"
#include "llfloaterreg.h"
#include "llfontgl.h"
@@ -55,6 +56,7 @@
#include "lltrans.h"
#include "llviewerassettype.h"
#include "llviewerinventory.h"
+#include "llviewercontrol.h"
#include "llviewerobject.h"
#include "llviewerregion.h"
#include "llviewerwindow.h"
@@ -82,9 +84,13 @@ bool LLPanelContents::postBuild()
{
setMouseOpaque(false);
- childSetAction("button new script",&LLPanelContents::onClickNewScript, this);
+ getChild<LLUICtrl>("button new script")->setCommitCallback(boost::bind(&LLPanelContents::onNewScriptFlyoutCommit, this, _1));
+ childSetAction("button new notecard", boost::bind(&LLPanelContents::onNewNotecardCommit, this));
childSetAction("button permissions",&LLPanelContents::onClickPermissions, this);
+ mPublishButton = getChild<LLButton>("button publish");
+ mPublishButton->setClickedCallback([this](LLUICtrl*, const LLSD&) { onClickPublish(); });
+
mFilterEditor = getChild<LLFilterEditor>("contents_filter");
mFilterEditor->setCommitCallback([&](LLUICtrl*, const LLSD&) { onFilterEdit(); });
@@ -114,6 +120,9 @@ void LLPanelContents::getState(LLViewerObject *objectp )
if( !objectp )
{
getChildView("button new script")->setEnabled(false);
+ getChildView("button new notecard")->setEnabled(false);
+ mPublishButton->setEnabled(false);
+ mPublishButton->setToggleState(false);
return;
}
@@ -126,15 +135,48 @@ void LLPanelContents::getState(LLViewerObject *objectp )
&& ( objectp->permYouOwner() || ( !group_id.isNull() && gAgent.isInGroup(group_id) ))); // solves SL-23488
bool all_volume = LLSelectMgr::getInstance()->selectionAllPCode( LL_PCODE_VOLUME );
+ S32 object_count = LLSelectMgr::getInstance()->getSelection()->getObjectCount();
+ S32 root_count = LLSelectMgr::getInstance()->getSelection()->getRootObjectCount();
+ bool single_root = (root_count == 1);
+
+ bool new_button_enabled = editable && all_volume && (single_root || (object_count == 1));
+
// Edit script button - ok if object is editable and there's an unambiguous destination for the object.
- getChildView("button new script")->setEnabled(
- editable &&
- all_volume &&
- ((LLSelectMgr::getInstance()->getSelection()->getRootObjectCount() == 1)
- || (LLSelectMgr::getInstance()->getSelection()->getObjectCount() == 1)));
+ getChildView("button new script")->setEnabled(new_button_enabled);
+
+ // Enable the Lua script option only when the region supports it.
+ bool lua_region = false;
+ LLViewerRegion* region = objectp->getRegion();
+ if (region && region->simulatorFeaturesReceived())
+ {
+ LLSD simulatorFeatures;
+ region->getSimulatorFeatures(simulatorFeatures);
+ lua_region = simulatorFeatures["LuaScriptsEnabled"].asBoolean();
+ }
+ getChild<LLComboBox>("button new script")->setEnabledByValue("lua", lua_region);
getChildView("button permissions")->setEnabled(!objectp->isPermanentEnforced());
mPanelInventoryObject->setEnabled(!objectp->isPermanentEnforced());
+
+
+
+ // New Notecard button - requires the CreateTaskInventoryItem cap.
+ bool has_create_cap = region && !region->getCapability("CreateTaskInventoryItem").empty();
+ getChildView("button new notecard")->setEnabled(has_create_cap && new_button_enabled);
+
+ // Publish button - enabled only when WS server is configured, and a single editable root object is selected.
+ mPublishButton->setEnabled(LLScriptEditorWSServer::isEnabled() && new_button_enabled);
+
+ // Sync toggle state to reflect whether the object is currently published.
+ if (LLScriptEditorWSServer::isEnabled())
+ {
+ auto server = LLScriptEditorWSServer::getServer();
+ mPublishButton->setToggleState(server && server->isObjectPublished(objectp->getID()));
+ }
+ else
+ {
+ mPublishButton->setToggleState(false);
+ }
}
void LLPanelContents::onFilterEdit()
@@ -221,29 +263,25 @@ void LLPanelContents::clearContents()
// Static functions
//
-// static
-void LLPanelContents::onClickNewScript(void *userdata)
+void LLPanelContents::onNewScriptFlyoutCommit(LLUICtrl* ctrl)
{
const bool children_ok = true;
LLViewerObject* object = LLSelectMgr::getInstance()->getSelection()->getFirstRootObject(children_ok);
- if (object)
- {
- LLPermissions perm;
- perm.init(gAgent.getID(), gAgent.getID(), LLUUID::null, LLUUID::null);
-
- // Parameters are base, owner, everyone, group, next
- perm.initMasks(
- PERM_ALL,
- PERM_ALL,
- LLFloaterPerms::getEveryonePerms("Scripts"),
- LLFloaterPerms::getGroupPerms("Scripts"),
- PERM_MOVE | LLFloaterPerms::getNextOwnerPerms("Scripts"));
- std::string desc;
- LLViewerAssetType::generateDescriptionFor(LLAssetType::AT_LSL_TEXT, desc);
-
- U8 script_language = SST_LSL;
- LLUUID template_id;
+ if (!object) return;
+ U8 script_language;
+ const std::string value = ctrl->getValue().asString();
+ if (value == "lsl")
+ {
+ script_language = SST_LSL;
+ }
+ else if (value == "lua")
+ {
+ script_language = SST_LUA;
+ }
+ else
+ {
+ script_language = SST_LSL;
LLViewerRegion* region = object->getRegion();
if (region && region->simulatorFeaturesReceived())
{
@@ -254,33 +292,89 @@ void LLPanelContents::onClickNewScript(void *userdata)
script_language = SST_LUA;
}
}
- // *TODO* Get a template ID and script_language based on user preferences. Template ID is the inventory item UUID of a script
- // in the user's inventory that is used as a template for new scripts.
+ }
+ std::string vm = (script_language == SST_LUA) ? "luau" : "mono";
+
+ LLSD params;
+ params["enabled"] = true;
+ params["vm"] = vm;
+
+ createTaskInventoryItemHelper(object,
+ LLAssetType::AT_LSL_TEXT,
+ LLInventoryType::IT_LSL,
+ script_language,
+ "New Script",
+ params);
+}
+
+void LLPanelContents::createTaskInventoryItemHelper(
+ LLViewerObject* object,
+ LLAssetType::EType asset_type,
+ LLInventoryType::EType inventory_type,
+ U8 sub_type,
+ const std::string& name,
+ const LLSD& params)
+{
+ const char* perm_key = (asset_type == LLAssetType::AT_LSL_TEXT) ? "Scripts" : "Notecards";
+
+ LLPermissions perm;
+ perm.init(gAgent.getID(), gAgent.getID(), LLUUID::null, LLUUID::null);
+ perm.initMasks(
+ PERM_ALL,
+ PERM_ALL,
+ LLFloaterPerms::getEveryonePerms(perm_key),
+ LLFloaterPerms::getGroupPerms(perm_key),
+ PERM_MOVE | LLFloaterPerms::getNextOwnerPerms(perm_key));
+
+ std::string desc;
+ LLViewerAssetType::generateDescriptionFor(asset_type, desc);
+
+ // Use cap if available, fall back to saveScript for scripts
+ if (!object->getRegion()->getCapability("CreateTaskInventoryItem").empty())
+ {
+ object->createInventoryItem(asset_type, inventory_type, sub_type,
+ name, desc, perm, params,
+ [](bool success, const LLSD& response)
+ {
+ if (!success)
+ {
+ LL_WARNS() << "CreateTaskInventoryItem failed: "
+ << response["message"].asString() << LL_ENDL;
+ }
+ });
+ }
+ else if (asset_type == LLAssetType::AT_LSL_TEXT)
+ {
+ // Fallback: use legacy RezScript UDP
LLPointer<LLViewerInventoryItem> new_item =
new LLViewerInventoryItem(
- LLUUID::null,
- LLUUID::null,
- perm,
- LLUUID::null,
- LLAssetType::AT_LSL_TEXT,
- LLInventoryType::IT_LSL,
- "New Script",
- desc,
- LLSaleInfo::DEFAULT,
- LLInventoryItemFlags::II_FLAGS_SUBTYPE_MASK & script_language,
+ LLUUID::null, LLUUID::null, perm,
+ LLUUID::null, asset_type, inventory_type,
+ name, desc, LLSaleInfo::DEFAULT,
+ LLInventoryItemFlags::II_FLAGS_SUBTYPE_MASK & sub_type,
time_corrected());
- object->saveScript(new_item, true, true, template_id);
+ object->saveScript(new_item, true, true, LLUUID::null);
+ }
+ else
+ {
+ LL_WARNS() << "Cannot create " << LLAssetType::lookup(asset_type)
+ << " — capability not available" << LL_ENDL;
+ }
+}
- std::string name = new_item->getName();
+void LLPanelContents::onNewNotecardCommit()
+{
+ const bool children_ok = true;
+ LLViewerObject* object = LLSelectMgr::getInstance()->getSelection()->getFirstRootObject(children_ok);
+ if (!object) return;
- // *NOTE: In order to resolve SL-22177, we needed to create
- // the script first, and then you have to click it in
- // inventory to edit it.
- // *TODO: The script creation should round-trip back to the
- // viewer so the viewer can auto-open the script and start
- // editing ASAP.
- }
+ createTaskInventoryItemHelper(object,
+ LLAssetType::AT_NOTECARD,
+ LLInventoryType::IT_NOTECARD,
+ 0,
+ "New Notecard",
+ LLSD());
}
// static
@@ -289,3 +383,40 @@ void LLPanelContents::onClickPermissions(void *userdata)
LLPanelContents* self = (LLPanelContents*)userdata;
gFloaterView->getParentFloater(self)->addDependentFloater(LLFloaterReg::showInstance("bulk_perms"));
}
+
+void LLPanelContents::onClickPublish()
+{
+ const bool children_ok = true;
+ LLViewerObject* object = LLSelectMgr::getInstance()->getSelection()->getFirstRootObject(children_ok);
+ if (!object)
+ {
+ LL_WARNS() << "No root object selected for publish/unpublish" << LL_ENDL;
+ return;
+ }
+
+ auto server = LLScriptEditorWSServer::ensureServerRunning();
+ if (!server)
+ {
+ LL_WARNS() << "Cannot publish/unpublish: WebSocket server failed to start" << LL_ENDL;
+ return;
+ }
+
+ const LLUUID object_id = object->getID();
+ if (server->getConnectionCount())
+ { // if we already have at least one connection, then we can toggle the publish state of the object
+ if (server->isObjectPublished(object_id))
+ {
+ server->unpublishObject(object_id, "user");
+ }
+ else
+ {
+ server->publishObject(object_id);
+ }
+ }
+ else
+ { // if we don't have any connections, we need to build the url and launch vscode
+ // Launch VSCode
+ LLScriptEditorWSServer::launchVSCode(object_id);
+
+ }
+}
diff --git a/indra/newview/llpanelcontents.h b/indra/newview/llpanelcontents.h
index 6e02b17bab..bbbd828f3c 100644
--- a/indra/newview/llpanelcontents.h
+++ b/indra/newview/llpanelcontents.h
@@ -33,6 +33,7 @@
#include "lluuid.h"
#include "llviewerobject.h"
#include "llvoinventorylistener.h"
+#include "llscripteditorws.h"
#include "v3math.h"
class LLButton;
@@ -52,8 +53,17 @@ public:
void clearContents();
- static void onClickNewScript(void*);
+ void onNewScriptFlyoutCommit(LLUICtrl* ctrl);
+ void onNewNotecardCommit();
static void onClickPermissions(void*);
+ void onClickPublish();
+
+ void createTaskInventoryItemHelper(LLViewerObject* object,
+ LLAssetType::EType asset_type,
+ LLInventoryType::EType inventory_type,
+ U8 sub_type,
+ const std::string& name,
+ const LLSD& params);
// Key suffix for "tentative" fields
static const char* TENTATIVE_SUFFIX;
@@ -76,6 +86,7 @@ public:
class LLFilterEditor* mFilterEditor;
LLSaveFolderState mSavedFolderState;
LLPanelObjectInventory* mPanelInventoryObject;
+ LLButton* mPublishButton { nullptr };
};
#endif // LL_LLPANELCONTENTS_H
diff --git a/indra/newview/llpanelobjectinventory.cpp b/indra/newview/llpanelobjectinventory.cpp
index d27ce81e4f..fc1f70ed73 100644
--- a/indra/newview/llpanelobjectinventory.cpp
+++ b/indra/newview/llpanelobjectinventory.cpp
@@ -897,9 +897,21 @@ public:
const std::string& name) :
LLTaskInvFVBridge(panel, uuid, name) {}
+ LLUIImagePtr getIcon() const override;
+
//static bool enableIfCopyable( void* userdata );
};
+// virtual
+LLUIImagePtr LLTaskScriptBridge::getIcon() const
+{
+ // Pass the item's flags so the script subtype (e.g. SST_LUA) is honored
+ // and the correct icon (Inv_Script vs Inv_Script_Luau) is selected.
+ LLInventoryItem* item = findItem();
+ U32 misc_flag = item ? item->getFlags() : 0;
+ return LLInventoryIcon::getIcon(mAssetType, mInventoryType, misc_flag, false);
+}
+
class LLTaskLSLBridge : public LLTaskScriptBridge
{
public:
diff --git a/indra/newview/llpreviewscript.cpp b/indra/newview/llpreviewscript.cpp
index 544473ba77..10bacf53b7 100644
--- a/indra/newview/llpreviewscript.cpp
+++ b/indra/newview/llpreviewscript.cpp
@@ -1130,32 +1130,58 @@ void LLScriptEdCore::openInExternalEditor()
std::string filename = mContainer->getTmpFileName(script_name);
- // Save the script to a temporary file.
- if (!writeToFile(filename))
+ if (LLScriptEditorWSServer::isTightIntegration())
{
- // In case some characters from script name are forbidden
- // and not accounted for, name is too long or some other issue,
- // try file that doesn't include script name
- script_name.clear();
- filename = mContainer->getTmpFileName(script_name);
- writeToFile(filename);
- }
+ // VS Code tight integration path.
+ // The extension opens the script as a virtual sl:// document; no temp file is needed.
+ auto server = LLScriptEditorWSServer::ensureServerRunning();
+ if (server)
+ {
+ mContainer->mWebSocketServer = server;
- if (mContainer->mLiveFile && mContainer->mLiveFile->filename() != filename)
- { // The name may have changed if we changed the type of scipt being edited.
- delete mContainer->mLiveFile;
- mContainer->mLiveFile = NULL;
+ LLViewerObject* object = gObjectList.findObject(mContainer->mObjectUUID);
+ LLViewerObject* root_object = object ? object->getRootEdit() : nullptr;
+ LLUUID root_id = root_object ? root_object->getID() : mContainer->mObjectUUID;
+
+ if (!LLScriptEditorWSServer::launchVSCode(root_id, mContainer->mItemUUID))
+ {
+ LLNotificationsUtil::add("GenericAlert",
+ LLSD().with("MESSAGE", LLTrans::getString("VSCodeLaunchFailed")));
+ }
+ }
+ else
+ {
+ LLNotificationsUtil::add("GenericAlert",
+ LLSD().with("MESSAGE", LLTrans::getString("ExternalEditorFailedToStart")));
+ }
}
- // Start watching file changes.
- if (!mContainer->mLiveFile)
+ else
{
- mContainer->mLiveFile = new LLLiveLSLFile(filename, boost::bind(&LLScriptEdContainer::onExternalChange, mContainer, _1));
- mContainer->mLiveFile->addToEventTimer();
- }
- mContainer->startWebsocketServer();
+ // Legacy external editor path: write temp file, watch it, open in external editor.
+ if (!writeToFile(filename))
+ {
+ // In case some characters from script name are forbidden
+ // and not accounted for, name is too long or some other issue,
+ // try file that doesn't include script name
+ script_name.clear();
+ filename = mContainer->getTmpFileName(script_name);
+ writeToFile(filename);
+ }
+
+ if (mContainer->mLiveFile && mContainer->mLiveFile->filename() != filename)
+ { // The name may have changed if we changed the type of script being edited.
+ delete mContainer->mLiveFile;
+ mContainer->mLiveFile = NULL;
+ }
+ // Start watching file changes.
+ if (!mContainer->mLiveFile)
+ {
+ mContainer->mLiveFile = new LLLiveLSLFile(filename, boost::bind(&LLScriptEdContainer::onExternalChange, mContainer, _1));
+ mContainer->mLiveFile->addToEventTimer();
+ }
+
+ mContainer->startWebsocketServer();
- // Open it in external editor.
- {
LLExternalEditor ed;
LLExternalEditor::EErrorCode status;
std::string msg;
@@ -1680,38 +1706,15 @@ bool LLScriptEdContainer::handleKeyHere(KEY key, MASK mask)
void LLScriptEdContainer::startWebsocketServer()
{
- if (gSavedSettings.getBOOL("ExternalWebsocketSyncEnable"))
+ auto server = LLScriptEditorWSServer::ensureServerRunning();
+ if (!server)
{
- // Attempt to find an existing server
- LLWebsocketMgr& wsmgr = LLWebsocketMgr::instance();
- LLScriptEditorWSServer::ptr_t server =
- std::static_pointer_cast<LLScriptEditorWSServer>(
- wsmgr.findServerByName(LLScriptEditorWSServer::DEFAULT_SERVER_NAME));
-
- if (!server)
- { // We couldn't find one, so create it
- U16 server_port = static_cast<U16>(gSavedSettings.getS32("ExternalWebsocketSyncPort"));
- bool server_localhost = gSavedSettings.getBOOL("ExternalWebsocketSyncLocal");
- server = std::make_shared<LLScriptEditorWSServer>(LLScriptEditorWSServer::DEFAULT_SERVER_NAME, server_port, server_localhost);
- wsmgr.addServer(server);
- }
-
- bool is_running = server->isRunning();
- if (!is_running)
- { // Server isn't running, so start it
- is_running = wsmgr.startServer(LLScriptEditorWSServer::DEFAULT_SERVER_NAME);
- }
-
- if (!is_running && !server->isRunning())
- { // Failed to start the server
- LL_WARNS() << "Failed to start script editor websocket server" << LL_ENDL;
- return;
- }
-
- std::string script_id_hash_str(getUniqueHash());
- server->subscribeScriptEditor(mObjectUUID, mItemUUID, mScriptEd->mScriptName, getHandle(), script_id_hash_str);
- mWebSocketServer = server;
+ return;
}
+
+ std::string script_id_hash_str(getUniqueHash());
+ server->subscribeScriptEditor(mObjectUUID, mItemUUID, mScriptEd->mScriptName, getHandle(), script_id_hash_str);
+ mWebSocketServer = server;
}
void LLScriptEdContainer::unsubscribeScript()
diff --git a/indra/newview/llscripteditorws.cpp b/indra/newview/llscripteditorws.cpp
index 3ca9be44bc..e0bf9b0b61 100644
--- a/indra/newview/llscripteditorws.cpp
+++ b/indra/newview/llscripteditorws.cpp
@@ -29,18 +29,178 @@
#include "llviewerprecompiledheaders.h"
#include "llscripteditorws.h"
-#include "llpreviewscript.h"
+
+#include "llagent.h"
#include "llappviewer.h"
-#include "lltrans.h"
+#include "llchat.h"
#include "lldate.h"
#include "llerror.h"
+#include "lleventcoro.h"
+#include "lleventfilter.h"
+#include "llevents.h"
+#include "llfilesystem.h"
+#include "llfloaterperms.h"
+#include "llfloaterreg.h"
+#include "llinventorytype.h"
+#include "llinventorydefines.h"
+#include "llnotecard.h"
+#include "llpreviewnotecard.h"
+#include "llpreviewscript.h"
+#include "llprocess.h"
+#include "llregex.h"
+#include "llsdjson.h"
+#include "llselectmgr.h"
+#include "lltrans.h"
#include "lluuid.h"
#include "llversioninfo.h"
-#include "llagent.h"
-#include "llregex.h"
+#include "llviewerassetstorage.h"
+#include "llviewerassettype.h"
+#include "llviewerassetupload.h"
+#include "llviewercontrol.h"
+#include "llviewerinventory.h"
#include "llviewerobject.h"
#include "llviewerobjectlist.h"
-#include "llchat.h"
+#include "llviewerregion.h"
+#include "llviewertexteditor.h"
+#include "llvoinventorylistener.h"
+#include "roles_constants.h"
+
+namespace
+{
+ // Per-operation timeouts (seconds) for coroutine-based async RPC handlers.
+ constexpr F32 ASSET_FETCH_TIMEOUT = 30.0f;
+ constexpr F32 SCRIPT_UPLOAD_TIMEOUT = 60.0f;
+ constexpr F32 NOTECARD_UPLOAD_TIMEOUT = 30.0f;
+ constexpr F32 ITEM_CREATE_TIMEOUT = 30.0f;
+
+ // Linkset flush coalescing delays (seconds).
+ constexpr F32 LINKSET_ADD_FLUSH_DELAY = 5.0f;
+ constexpr F32 LINKSET_REMOVE_FLUSH_DELAY = 0.2f;
+
+ // Creates a uniquely-named LLEventMailDrop under "<prefix>.<uuid>", passes
+ // its name to kickoff (which arranges for one post to that pump), then
+ // suspends the current coroutine up to imeout seconds for the result.
+ // Throws RequestTimeoutError(timeout_msg) if the deadline elapses.
+ template <typename Kickoff>
+ LLSD await_async_result(const std::string& pump_prefix,
+ F32 timeout,
+ const std::string& timeout_msg,
+ Kickoff&& kickoff)
+ {
+ LLEventMailDrop pump(pump_prefix + "." + LLUUID::generateNewID().asString(), true);
+ std::string pump_name = pump.getName();
+ std::forward<Kickoff>(kickoff)(pump_name);
+ LLSD result = llcoro::suspendUntilEventOnWithTimeout(
+ pump, timeout, LLSD().with("timeout", true));
+ if (result.has("timeout"))
+ {
+ throw LLJSONRPCConnection::RequestTimeoutError(timeout_msg);
+ }
+ return result;
+ }
+
+ // Builds the (success, failure) callback pair used by LLResourceUploadInfo-
+ // derived uploads. Both outcomes post a single LLSD to pump_name:
+ // - success: the server's response LLSD with item_id/task_id added.
+ // - failure: { "failed": true, "reason": <reason> }.
+ auto make_asset_upload_callbacks(const std::string& pump_name)
+ {
+ auto on_success = [pump_name](LLUUID item_id, LLUUID task_id, LLUUID /*new_asset_id*/, LLSD response)
+ {
+ response["item_id"] = item_id;
+ response["task_id"] = task_id;
+ LLEventPumps::instance().post(pump_name, response);
+ };
+ auto on_failure = [pump_name](LLUUID /*item_id*/, LLUUID /*task_id*/, LLSD /*response*/, std::string reason)
+ {
+ LLSD failure;
+ failure["failed"] = true;
+ failure["reason"] = reason;
+ LLEventPumps::instance().post(pump_name, failure);
+ return false;
+ };
+ return std::make_pair(std::move(on_success), std::move(on_failure));
+ }
+
+ // Returns [root, *root->getChildren()] in stable order. Root must be non-null.
+ std::vector<LLViewerObject*> collect_linkset(LLViewerObject* root)
+ {
+ std::vector<LLViewerObject*> prims;
+ const auto& children = root->getChildren();
+ prims.reserve(1 + children.size());
+ prims.push_back(root);
+ for (LLViewerObject* child : children)
+ {
+ prims.push_back(child);
+ }
+ return prims;
+ }
+
+ // Returns the value of NV pair key on obj as a string, or empty if
+ // obj / pair / string is null or empty. NUL-safe.
+ std::string nv_string(LLViewerObject* obj, const char* key)
+ {
+ if (!obj)
+ {
+ return std::string();
+ }
+ LLNameValue* nv = obj->getNVPair(key);
+ if (!nv)
+ {
+ return std::string();
+ }
+ const char* s = nv->getString();
+ if (!s || s[0] == '\0')
+ {
+ return std::string();
+ }
+ return std::string(s);
+ }
+}
+
+LLCachedControl<bool> LLScriptEditorWSServer::sEnableScriptEditorWS(gSavedSettings, "ExternalWebsocketSyncEnable", false);
+LLCachedControl<bool> LLScriptEditorWSServer::sTightIntegration(gSavedSettings, "ExternalEditorTightIntegration", false);
+
+
+class LLPublishedPrimListener : public LLVOInventoryListener
+{
+public:
+ LLPublishedPrimListener(LLScriptEditorWSServer* server, const LLUUID& object_id, const LLUUID& prim_id,
+ LLViewerObject* object)
+ : mServer(server)
+ , mObjectID(object_id)
+ , mPrimID(prim_id)
+ {
+ registerVOInventoryListener(object, nullptr);
+ }
+
+ ~LLPublishedPrimListener() override = default;
+
+ void inventoryChanged(LLViewerObject* object,
+ LLInventoryObject::object_list_t* inventory,
+ S32 serial_num, void* user_data) override
+ {
+ if (mServer)
+ {
+ if (mServer->isObjectPublished(mObjectID))
+ {
+ mServer->onPrimInventoryChanged(mObjectID, mPrimID);
+ }
+ else
+ {
+ mServer->onPrimInventoryReady(mObjectID, mPrimID);
+ }
+ }
+ }
+
+ const LLUUID& getObjectID() const { return mObjectID; }
+ const LLUUID& getPrimID() const { return mPrimID; }
+
+private:
+ LLScriptEditorWSServer* mServer; // non-owning; server always outlives listeners
+ LLUUID mObjectID; // root object this prim belongs to
+ LLUUID mPrimID; // this specific prim
+};
//========================================================================
LLScriptEditorWSServer::LLScriptEditorWSServer(const std::string& name, U16 port, bool local_only)
@@ -61,6 +221,100 @@ LLScriptEditorWSServer::ptr_t LLScriptEditorWSServer::getServer()
wsmgr.findServerByName(LLScriptEditorWSServer::DEFAULT_SERVER_NAME));
}
+LLScriptEditorWSServer::ptr_t LLScriptEditorWSServer::ensureServerRunning()
+{
+ if (!LLScriptEditorWSServer::isEnabled())
+ {
+ LL_DEBUGS("ScriptEditorWS") << "WebSocket server is disabled by ExternalWebsocketSyncEnable" << LL_ENDL;
+ return nullptr;
+ }
+
+ LLWebsocketMgr& wsmgr = LLWebsocketMgr::instance();
+ ptr_t server = std::static_pointer_cast<LLScriptEditorWSServer>(
+ wsmgr.findServerByName(DEFAULT_SERVER_NAME));
+
+ if (!server)
+ {
+ U16 port = static_cast<U16>(gSavedSettings.getS32("ExternalWebsocketSyncPort"));
+ bool local_only = gSavedSettings.getBOOL("ExternalWebsocketSyncLocal");
+ server = std::make_shared<LLScriptEditorWSServer>(DEFAULT_SERVER_NAME, port, local_only);
+ wsmgr.addServer(server);
+ }
+
+ if (!server->isRunning())
+ {
+ if (!wsmgr.startServer(DEFAULT_SERVER_NAME))
+ {
+ LL_WARNS("ScriptEditorWS") << "Failed to start script editor websocket server" << LL_ENDL;
+ return nullptr;
+ }
+ }
+
+ return server;
+}
+
+std::string LLScriptEditorWSServer::buildVSCodeURI(const LLUUID& object_id,
+ const LLUUID& script_id)
+{
+ std::ostringstream uri;
+ uri << "vscode://lindenlab.sl-vscode-plugin/connect";
+
+ U16 port = static_cast<U16>(gSavedSettings.getS32("ExternalWebsocketSyncPort"));
+ uri << "?port=" << port;
+
+ if (object_id.notNull())
+ {
+ uri << "&object=" << object_id.asString();
+ }
+
+ if (script_id.notNull())
+ {
+ uri << "&script=" << script_id.asString();
+ }
+
+ return uri.str();
+}
+
+bool LLScriptEditorWSServer::launchVSCode(const LLUUID& object_id,
+ const LLUUID& script_id)
+{
+ ptr_t server = ensureServerRunning();
+ if (!server)
+ {
+ LL_WARNS("ScriptEditorWS") << "Cannot launch VS Code: WebSocket server failed to start" << LL_ENDL;
+ return false;
+ }
+
+ std::string uri = buildVSCodeURI(object_id, script_id);
+
+ LLProcess::Params params;
+#if LL_WINDOWS
+ // On Windows, VS Code's 'code' is a batch file (.cmd) which APR cannot
+ // launch directly. Invoke it through cmd.exe instead.
+ // The URI may contain '&' which cmd.exe treats as a command separator,
+ // so the entire argument list is passed as a single quoted string.
+ params.executable = "cmd.exe";
+ params.args.add("/c");
+ params.args.add("code --open-url \"" + uri + "\"");
+#else
+ params.executable = "code";
+ params.args.add("--open-url");
+ params.args.add(uri);
+#endif
+ params.autokill = false;
+
+ LLProcessPtr process = LLProcess::create(params);
+ if (!process)
+ {
+ LL_WARNS("ScriptEditorWS") << "Failed to launch VS Code. "
+ << "Ensure the 'code' command is available on your PATH." << LL_ENDL;
+ return false;
+ }
+
+ LL_INFOS("ScriptEditorWS") << "Launched VS Code with URI: " << uri << LL_ENDL;
+ return true;
+}
+
LLWebsocketMgr::WSConnection::ptr_t LLScriptEditorWSServer::connectionFactory(LLWebsocketMgr::WSServer::ptr_t server,
LLWebsocketMgr::connection_h handle)
@@ -95,6 +349,26 @@ void LLScriptEditorWSServer::onStopped()
{
mLanguageChangeSignal.disconnect();
mLastSyntaxId.setNull();
+
+ // Connections are already closed -- clean up all internal state silently.
+ // Do not attempt to send notifications; the sockets are gone.
+
+ for (auto& [id, pending] : mPendingPublishes)
+ {
+ pending.mListeners.clear();
+ }
+ mPendingPublishes.clear();
+
+ for (auto& [id, info] : mPublishedObjects)
+ {
+ info.mListeners.clear();
+ }
+ mPublishedObjects.clear();
+
+ mSubscriptions.clear();
+ mActiveConnections.clear();
+
+ LL_INFOS("ScriptEditorWS") << "Script editor WebSocket server stopped, all state cleaned up" << LL_ENDL;
}
void LLScriptEditorWSServer::onConnectionOpened(const LLWebsocketMgr::WSConnection::ptr_t& connection)
@@ -130,22 +404,24 @@ void LLScriptEditorWSServer::onConnectionClosed(const LLWebsocketMgr::WSConnecti
bool LLScriptEditorWSServer::subscribeScriptEditor(const LLUUID& object_id, const LLUUID& item_id, std::string_view script_name,
const LLHandle<LLPanel>& editor_handle, const std::string& script_id)
{
- if (!editor_handle.isDead())
+ 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;
+ }
+
+ auto it = mSubscriptions.find(script_id);
+ if (it == mSubscriptions.end())
+ {
+ // New subscription
+ mSubscriptions.emplace(script_id,
+ EditorSubscription(object_id, item_id, script_name, editor_handle));
+ }
+ else
+ {
+ // Refresh existing subscription with the new editor handle
+ it->second.mEditorHandle = editor_handle;
}
- return false;
+ return true;
}
void LLScriptEditorWSServer::unsubscribeEditor(const std::string &script_id)
@@ -156,14 +432,32 @@ void LLScriptEditorWSServer::unsubscribeEditor(const std::string &script_id)
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)
+
+ // Maintain per-connection count; erase entry when it hits zero.
+ bool last_for_connection = false;
+ if (connection_id != 0)
+ {
+ auto cit = mConnectionSubscriptionCounts.find(connection_id);
+ if (cit != mConnectionSubscriptionCounts.end())
+ {
+ if (--cit->second <= 0)
+ {
+ mConnectionSubscriptionCounts.erase(cit);
+ last_for_connection = true;
+ }
+ }
+ else
+ {
+ // No counter entry means no other subs referenced this connection.
+ last_for_connection = true;
+ }
+ }
+
+ if (connection && last_for_connection)
{ // 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");
+ connection->sendDisconnect(LLScriptEditorWSConnection::DisconnectReason::EDITOR_CLOSED, "Editor closed");
}
}
@@ -181,9 +475,11 @@ void LLScriptEditorWSServer::unsubscribeConnection(U32 connection_id)
it->second.mConnection.reset();
}
}
+ // All subs for this connection now have mConnectionID == 0.
+ mConnectionSubscriptionCounts.erase(connection_id);
}
-LLScriptEditorWSServer::SubscriptionError_t LLScriptEditorWSServer::updateScriptSubscription(const std::string &script_id, U32 connection_id)
+LLScriptEditorWSServer::SubscriptionError LLScriptEditorWSServer::updateScriptSubscription(const std::string &script_id, U32 connection_id)
{
auto it = mSubscriptions.find(script_id);
if (it != mSubscriptions.end())
@@ -191,13 +487,13 @@ LLScriptEditorWSServer::SubscriptionError_t LLScriptEditorWSServer::updateScript
if (it->second.mEditorHandle.isDead())
{
unsubscribeEditor(script_id);
- return SUBSCRIPTION_INVALID_EDITOR;
+ return SubscriptionError::INVALID_EDITOR;
}
auto con_it = mActiveConnections.find(connection_id);
if (con_it == mActiveConnections.end())
{
- return SUBSCRIPTION_INTERNAL_ERROR;
+ return SubscriptionError::INTERNAL_ERROR;
}
if ((it->second.mConnectionID != 0) && !it->second.mConnection.expired()
@@ -207,14 +503,18 @@ LLScriptEditorWSServer::SubscriptionError_t LLScriptEditorWSServer::updateScript
<< ", 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;
+ return SubscriptionError::ALREADY_SUBSCRIBED;
}
+ // If this entry was previously bound to a different (dead) connection,
+ // it would have been cleared by unsubscribeConnection, so mConnectionID
+ // is always 0 here.
it->second.mConnectionID = connection_id;
it->second.mConnection = con_it->second;
- return SUBSCRIPTION_SUCCESS;
+ ++mConnectionSubscriptionCounts[connection_id];
+ return SubscriptionError::SUCCESS;
}
- return SUBSCRIPTION_INVALID_SUBSCRIPTION;
+ return SubscriptionError::INVALID_SUBSCRIPTION;
}
@@ -251,78 +551,413 @@ void LLScriptEditorWSServer::setupConnectionMethods(LLJSONRPCConnection::ptr_t c
if (script_connection)
{
LL_DEBUGS("ScriptEditorWS") << "Setting up script editor connection methods" << LL_ENDL;
- wptr_t that(std::static_pointer_cast<LLScriptEditorWSServer>(shared_from_this()));
-
U32 connection_id = script_connection->getConnectionID();
+ // Sync methods (run on the WebSocket I/O thread; must not touch
+ // main-thread-only viewer state).
script_connection->registerMethod("language.syntax.id",
- [that](const std::string&, const LLSD&, const LLSD&) -> LLSD
+ bindHandler([](LLScriptEditorWSServer& s, auto&, auto&, auto&)
{
- auto server = that.lock();
- if (server)
- {
- return server->handleLanguageIdRequest();
- }
- return LLSD();
- });
+ return s.handleLanguageIdRequest();
+ }));
+
script_connection->registerMethod("language.syntax",
- [that](const std::string&, const LLSD&, const LLSD& params)
+ bindHandler([](LLScriptEditorWSServer& s, auto&, auto&, const LLSD& params)
{
- auto server = that.lock();
- if (server)
- {
- return server->handleSyntaxRequest(params);
- }
- return LLSD();
- });
+ return s.handleSyntaxRequest(params);
+ }));
+
script_connection->registerMethod("language.syntax.cache",
- [that](const std::string&, const LLSD&, const LLSD& params)
+ bindHandler([](LLScriptEditorWSServer& s, auto&, auto&, auto&)
{
- auto server = that.lock();
- if (server)
- {
- return server->handleSyntaxCacheRequest();
- }
- return LLSD();
- });
+ return s.handleSyntaxCacheRequest();
+ }));
+
script_connection->registerMethod("language.syntax.get",
- [that](const std::string&, const LLSD&, const LLSD& params)
+ bindHandler([](LLScriptEditorWSServer& s, auto&, auto&, const LLSD& params)
{
- auto server = that.lock();
- if (server)
- {
- return server->handleSyntaxCacheFileRequest(params);
- }
- return LLSD();
- });
+ return s.handleSyntaxCacheFileRequest(params);
+ }));
+
script_connection->registerMethod("script.subscribe",
- [that, connection_id](const std::string&, const LLSD&, const LLSD& params) -> LLSD
+ bindHandler([connection_id](LLScriptEditorWSServer& s, auto&, auto&, const LLSD& params)
{
- 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();
- });
+ return s.handleScriptSubscribe(connection_id, params);
+ }));
+
script_connection->registerMethod("script.list",
- [that](const std::string&, const LLSD&, const LLSD& params) -> LLSD
+ bindHandler([](LLScriptEditorWSServer& s, auto&, auto&, auto&)
{
- auto server = that.lock();
- if (server)
- {
- return server->handleFileWatcherFileListRequest();
- }
- return LLSD();
- });
- // script_connection->registerMethod("language.syntax", )
+ return s.handleFileWatcherFileListRequest();
+ }));
+
+ script_connection->registerMethod("object.unpublish",
+ bindHandler([connection_id](LLScriptEditorWSServer& s, auto&, auto&, const LLSD& params)
+ {
+ return s.handleObjectUnpublish(connection_id, params);
+ }));
+
+ // Async methods (dispatched to the main thread inside a coroutine).
+ script_connection->registerAsyncMethod("script.unsubscribe",
+ bindHandler([connection_id](LLScriptEditorWSServer& s, auto&, auto&, const LLSD& params)
+ {
+ return s.handleScriptUnsubscribe(connection_id, params);
+ }));
+
+ script_connection->registerAsyncMethod("object.request",
+ bindHandler([connection_id](LLScriptEditorWSServer& s, auto&, auto&, const LLSD& params)
+ {
+ return s.handleObjectRequest(connection_id, params);
+ }));
+
+ script_connection->registerAsyncMethod("object.content.get",
+ bindHandler([](LLScriptEditorWSServer& s, const std::string& method, const LLSD& id, const LLSD& params)
+ {
+ return s.handleObjectContentGet(method, id, params);
+ }));
+
+ script_connection->registerAsyncMethod("object.content.save",
+ bindHandler([](LLScriptEditorWSServer& s, const std::string& method, const LLSD& id, const LLSD& params)
+ {
+ return s.handleObjectContentSave(method, id, params);
+ }));
+
+ script_connection->registerAsyncMethod("object.item.delete",
+ bindHandler([connection_id](LLScriptEditorWSServer& s, auto&, auto&, const LLSD& params)
+ {
+ return s.handleObjectItemDelete(connection_id, params);
+ }));
+
+ script_connection->registerAsyncMethod("object.item.create",
+ bindHandler([](LLScriptEditorWSServer& s, const std::string& method, const LLSD& id, const LLSD& params)
+ {
+ return s.handleObjectItemCreate(method, id, params);
+ }));
+
+ script_connection->registerAsyncMethod("object.list",
+ bindHandler([](LLScriptEditorWSServer& s, auto&, auto&, auto&)
+ {
+ return s.handleObjectList();
+ }));
+
+ script_connection->registerAsyncMethod("object.script.set_running",
+ bindHandler([connection_id](LLScriptEditorWSServer& s, auto&, auto&, const LLSD& params)
+ {
+ return s.handleObjectScriptSetRunning(connection_id, params);
+ }));
+
+ script_connection->registerAsyncMethod("object.script.reset",
+ bindHandler([connection_id](LLScriptEditorWSServer& s, auto&, auto&, const LLSD& params)
+ {
+ return s.handleObjectScriptReset(connection_id, params);
+ }));
+
+ script_connection->registerAsyncMethod("object.modify",
+ bindHandler([connection_id](LLScriptEditorWSServer& s, auto&, auto&, const LLSD& params)
+ {
+ return s.handleObjectModify(connection_id, params);
+ }));
+
+ script_connection->registerAsyncMethod("object.item.modify",
+ bindHandler([connection_id](LLScriptEditorWSServer& s, auto&, auto&, const LLSD& params)
+ {
+ return s.handleObjectItemModify(connection_id, params);
+ }));
}
}
+LLSD LLScriptEditorWSServer::handleObjectList() const
+{
+ LLSD objects = LLSD::emptyArray();
+ for (const auto& [object_id, info] : mPublishedObjects)
+ {
+ LLViewerObject* root = gObjectList.findObject(object_id);
+ if (!root)
+ {
+ LL_DEBUGS("ScriptEditorWS") << "object.list: skipping " << object_id
+ << " (no longer in scene)" << LL_ENDL;
+ continue;
+ }
+
+ // Use cached names from PublishedObjectInfo, but fetch live inventory
+ LLSD pub;
+ pub["object_id"] = info.mObjectID;
+ pub["object_name"] = info.mObjectName;
+ pub["object_description"] = info.mObjectDescription;
+ pub["owner_id"] = info.mOwnerID;
+ if (!info.mRegionName.empty())
+ {
+ pub["region"] = info.mRegionName;
+ }
+ pub["inventory"] = buildPrimInventoryLLSD(root);
+
+ LLSD linked_objects = LLSD::emptyArray();
+ for (const auto& prim_info : info.mPrims)
+ {
+ if (prim_info.mLinkNumber == 1) continue; // skip root
+
+ LLViewerObject* child = gObjectList.findObject(prim_info.mPrimID);
+ if (!child) continue;
+
+ LLSD link;
+ link["link_id"] = prim_info.mPrimID;
+ link["link_number"] = prim_info.mLinkNumber;
+ link["link_name"] = prim_info.mPrimName; // Cached name
+ link["inventory"] = buildPrimInventoryLLSD(child);
+ linked_objects.append(link);
+ }
+ if (linked_objects.size() > 0)
+ {
+ pub["linked_objects"] = linked_objects;
+ }
+
+ objects.append(pub);
+ }
+
+ LLSD response;
+ response["objects"] = objects;
+ return response;
+}
+
+LLSD LLScriptEditorWSServer::handleObjectScriptSetRunning(U32 connection_id, const LLSD& params)
+{
+ LLUUID prim_id = params["prim_id"].asUUID();
+ LLUUID item_id = params["item_id"].asUUID();
+ bool running = params["running"].asBoolean();
+
+ if (prim_id.isNull() || item_id.isNull())
+ throw LLJSONRPCConnection::InvalidParams("prim_id and item_id are required");
+
+ LLViewerObject* prim = gObjectList.findObject(prim_id);
+ if (!prim)
+ throw LLJSONRPCConnection::InvalidParams("Prim not found");
+
+ LLViewerObject* root = prim->getRootEdit();
+ if (!root || !isObjectPublished(root->getID()))
+ throw LLJSONRPCConnection::ForbiddenError("Object is not published");
+
+ LLInventoryItem* item = dynamic_cast<LLInventoryItem*>(prim->getInventoryObject(item_id));
+ if (!item)
+ throw LLJSONRPCConnection::InvalidParams("Script not found in prim inventory");
+
+ if (item->getType() != LLAssetType::AT_LSL_TEXT)
+ throw LLJSONRPCConnection::InvalidParams("Item is not a script");
+
+ if (!gAgent.allowOperation(PERM_MODIFY, item->getPermissions(), GP_OBJECT_MANIPULATE))
+ throw LLJSONRPCConnection::ForbiddenError("No modify permission on script");
+
+ // Send SetScriptRunning message to simulator
+ LLMessageSystem* msg = gMessageSystem;
+ msg->newMessageFast(_PREHASH_SetScriptRunning);
+ msg->nextBlockFast(_PREHASH_AgentData);
+ msg->addUUIDFast(_PREHASH_AgentID, gAgent.getID());
+ msg->addUUIDFast(_PREHASH_SessionID, gAgent.getSessionID());
+ msg->nextBlockFast(_PREHASH_Script);
+ msg->addUUIDFast(_PREHASH_ObjectID, prim_id);
+ msg->addUUIDFast(_PREHASH_ItemID, item_id);
+ msg->addBOOLFast(_PREHASH_Running, running);
+ msg->sendReliable(prim->getRegion()->getHost());
+
+ LLSD response;
+ response["success"] = true;
+ return response;
+}
+
+LLSD LLScriptEditorWSServer::handleObjectScriptReset(U32 connection_id, const LLSD& params)
+{
+ LLUUID prim_id = params["prim_id"].asUUID();
+ LLUUID item_id = params["item_id"].asUUID();
+
+ if (prim_id.isNull() || item_id.isNull())
+ throw LLJSONRPCConnection::InvalidParams("prim_id and item_id are required");
+
+ LLViewerObject* prim = gObjectList.findObject(prim_id);
+ if (!prim)
+ throw LLJSONRPCConnection::InvalidParams("Prim not found");
+
+ LLViewerObject* root = prim->getRootEdit();
+ if (!root || !isObjectPublished(root->getID()))
+ throw LLJSONRPCConnection::ForbiddenError("Object is not published");
+
+ LLInventoryItem* item = dynamic_cast<LLInventoryItem*>(prim->getInventoryObject(item_id));
+ if (!item)
+ throw LLJSONRPCConnection::InvalidParams("Script not found in prim inventory");
+
+ if (item->getType() != LLAssetType::AT_LSL_TEXT)
+ throw LLJSONRPCConnection::InvalidParams("Item is not a script");
+
+ if (!gAgent.allowOperation(PERM_MODIFY, item->getPermissions(), GP_OBJECT_MANIPULATE))
+ throw LLJSONRPCConnection::ForbiddenError("No modify permission on script");
+
+ // Send ScriptReset message to simulator
+ LLMessageSystem* msg = gMessageSystem;
+ msg->newMessageFast(_PREHASH_ScriptReset);
+ msg->nextBlockFast(_PREHASH_AgentData);
+ msg->addUUIDFast(_PREHASH_AgentID, gAgent.getID());
+ msg->addUUIDFast(_PREHASH_SessionID, gAgent.getSessionID());
+ msg->nextBlockFast(_PREHASH_Script);
+ msg->addUUIDFast(_PREHASH_ObjectID, prim_id);
+ msg->addUUIDFast(_PREHASH_ItemID, item_id);
+ msg->sendReliable(prim->getRegion()->getHost());
+
+ LLSD response;
+ response["success"] = true;
+ return response;
+}
+
+LLSD LLScriptEditorWSServer::handleObjectModify(U32 connection_id, const LLSD& params)
+{
+ // ─────────────────────────────────────────────────────────────
+ // Step 1: Parameter Validation
+ // ─────────────────────────────────────────────────────────────
+ LLUUID prim_id = params["prim_id"].asUUID();
+ if (prim_id.isNull())
+ throw LLJSONRPCConnection::InvalidParams("prim_id is required");
+
+ bool has_name = params.has("name");
+ bool has_desc = params.has("description");
+ bool has_perms = params.has("permissions") && params["permissions"].has("next_owner");
+
+ if (!has_name && !has_desc && !has_perms)
+ throw LLJSONRPCConnection::InvalidParams(
+ "At least one property (name, description, or permissions) must be specified");
+
+ // ─────────────────────────────────────────────────────────────
+ // Step 2: Find and Validate Object
+ // ─────────────────────────────────────────────────────────────
+ LLViewerObject* prim = gObjectList.findObject(prim_id);
+ if (!prim)
+ throw LLJSONRPCConnection::InvalidParams("Prim not found");
+
+ LLViewerObject* root = prim->getRootEdit();
+ if (!root || !isObjectPublished(root->getID()))
+ throw LLJSONRPCConnection::ForbiddenError("Object is not published");
+
+ if (!prim->permModify())
+ throw LLJSONRPCConnection::ForbiddenError("No modify permission on object");
+
+ // ─────────────────────────────────────────────────────────────
+ // Step 3: Send Property Update Messages
+ // ─────────────────────────────────────────────────────────────
+ LLMessageSystem* msg = gMessageSystem;
+ LLHost host = prim->getRegion()->getHost();
+ U32 local_id = prim->getLocalID();
+
+ if (has_name)
+ {
+ std::string new_name = params["name"].asString();
+ msg->newMessageFast(_PREHASH_ObjectName);
+ msg->nextBlockFast(_PREHASH_AgentData);
+ msg->addUUIDFast(_PREHASH_AgentID, gAgent.getID());
+ msg->addUUIDFast(_PREHASH_SessionID, gAgent.getSessionID());
+ msg->nextBlockFast(_PREHASH_ObjectData);
+ msg->addU32Fast(_PREHASH_LocalID, local_id);
+ msg->addStringFast(_PREHASH_Name, new_name);
+ msg->sendReliable(host);
+ }
+
+ if (has_desc)
+ {
+ std::string new_desc = params["description"].asString();
+ msg->newMessageFast(_PREHASH_ObjectDescription);
+ msg->nextBlockFast(_PREHASH_AgentData);
+ msg->addUUIDFast(_PREHASH_AgentID, gAgent.getID());
+ msg->addUUIDFast(_PREHASH_SessionID, gAgent.getSessionID());
+ msg->nextBlockFast(_PREHASH_ObjectData);
+ msg->addU32Fast(_PREHASH_LocalID, local_id);
+ msg->addStringFast(_PREHASH_Description, new_desc);
+ msg->sendReliable(host);
+ }
+
+ if (has_perms)
+ {
+ U32 next_owner_mask = static_cast<U32>(params["permissions"]["next_owner"].asInteger());
+ msg->newMessageFast(_PREHASH_ObjectPermissions);
+ msg->nextBlockFast(_PREHASH_AgentData);
+ msg->addUUIDFast(_PREHASH_AgentID, gAgent.getID());
+ msg->addUUIDFast(_PREHASH_SessionID, gAgent.getSessionID());
+ msg->nextBlockFast(_PREHASH_HeaderData);
+ msg->addBOOLFast(_PREHASH_Override, false);
+ msg->nextBlockFast(_PREHASH_ObjectData);
+ msg->addU32Fast(_PREHASH_ObjectLocalID, local_id);
+ msg->addU8Fast(_PREHASH_Field, PERM_NEXT_OWNER);
+ msg->addBOOLFast(_PREHASH_Set, true);
+ msg->addU32Fast(_PREHASH_Mask, next_owner_mask);
+ msg->sendReliable(host);
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Step 4: Return Success Response
+ // ─────────────────────────────────────────────────────────────
+ LLSD response;
+ response["success"] = true;
+ response["prim_id"] = prim_id.asString();
+ return response;
+}
+
+LLSD LLScriptEditorWSServer::handleObjectItemModify(U32 connection_id, const LLSD& params)
+{
+ // ─────────────────────────────────────────────────────────────
+ // Step 1: Parameter Validation
+ // ─────────────────────────────────────────────────────────────
+ if (!params.has("prim_id") || !params.has("item_id"))
+ throw LLJSONRPCConnection::InvalidParams("prim_id and item_id are required");
+
+ bool has_name = params.has("name");
+ bool has_desc = params.has("description");
+ bool has_perms = params.has("permissions") && params["permissions"].has("next_owner");
+
+ if (!has_name && !has_desc && !has_perms)
+ throw LLJSONRPCConnection::InvalidParams(
+ "At least one property (name, description, or permissions) must be specified");
+
+ // ─────────────────────────────────────────────────────────────
+ // Step 2: Validate Published Item (reuse existing helper)
+ // ─────────────────────────────────────────────────────────────
+ ValidatedItem v = validatePublishedItem(params, PERM_MODIFY);
+
+ LLUUID prim_id = params["prim_id"].asUUID();
+ LLUUID item_id = params["item_id"].asUUID();
+
+ // ─────────────────────────────────────────────────────────────
+ // Step 3: Create Modified Item Copy
+ // ─────────────────────────────────────────────────────────────
+ LLPointer<LLViewerInventoryItem> new_item =
+ new LLViewerInventoryItem(static_cast<LLViewerInventoryItem*>(v.item));
+
+ if (has_name)
+ {
+ new_item->rename(params["name"].asString());
+ }
+
+ if (has_desc)
+ {
+ new_item->setDescription(params["description"].asString());
+ }
+
+ if (has_perms)
+ {
+ LLPermissions perm = new_item->getPermissions();
+ U32 next_owner_mask = static_cast<U32>(params["permissions"]["next_owner"].asInteger());
+ perm.setMaskNext(next_owner_mask);
+ new_item->setPermissions(perm);
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Step 4: Send UpdateTaskInventory Message
+ // ─────────────────────────────────────────────────────────────
+ v.prim->updateInventory(new_item, TASK_INVENTORY_ITEM_KEY, false);
+
+ // ─────────────────────────────────────────────────────────────
+ // Step 5: Return Success Response
+ // ─────────────────────────────────────────────────────────────
+ LLSD response;
+ response["success"] = true;
+ response["prim_id"] = prim_id.asString();
+ response["item_id"] = item_id.asString();
+ return response;
+}
+
void LLScriptEditorWSServer::broadcastLanguageChange()
{
LLUUID syntax_id = LLSyntaxDefCache::instance().getSyntaxID();
@@ -452,34 +1087,34 @@ LLSD LLScriptEditorWSServer::handleScriptSubscribe(U32 connection_id, const LLSD
std::string script_name = params["script_name"].asString();
std::string language = params["script_language"].asString();
- SubscriptionError_t result = updateScriptSubscription(script_id, connection_id);
+ SubscriptionError result = updateScriptSubscription(script_id, connection_id);
response["script_id"] = script_id;
- response["success"] = (result == SUBSCRIPTION_SUCCESS);
- response["status"] = result;
+ response["success"] = (result == SubscriptionError::SUCCESS);
+ response["status"] = static_cast<S32>(result);
- LL_WARNS_IF(result != SUBSCRIPTION_SUCCESS, "ScriptEditorWS")
- << "Script connect request for script " << script_id << " failed with status " << result << LL_ENDL;
+ LL_WARNS_IF(result != SubscriptionError::SUCCESS, "ScriptEditorWS")
+ << "Script connect request for script " << script_id << " failed with status " << static_cast<S32>(result) << LL_ENDL;
switch (result)
{
- case SUBSCRIPTION_SUCCESS:
+ case SubscriptionError::SUCCESS:
response["message"] = "OK";
break;
- case SUBSCRIPTION_INVALID_EDITOR:
+ case SubscriptionError::INVALID_EDITOR:
response["message"] = "Invalid editor handle";
break;
- case SUBSCRIPTION_INVALID_SUBSCRIPTION:
+ case SubscriptionError::INVALID_SUBSCRIPTION:
response["message"] = "No subscription found for script";
break;
- case SUBSCRIPTION_ALREADY_SUBSCRIBED:
+ case SubscriptionError::ALREADY_SUBSCRIBED:
response["message"] = "Script already subscribed";
break;
- case SUBSCRIPTION_INTERNAL_ERROR:
+ case SubscriptionError::INTERNAL_ERROR:
response["message"] = "Internal server error";
break;
}
- if (result == SUBSCRIPTION_SUCCESS)
+ if (result == SubscriptionError::SUCCESS)
{
auto it = mSubscriptions.find(script_id);
if (it != mSubscriptions.end())
@@ -525,8 +1160,605 @@ LLSD LLScriptEditorWSServer::handleFileWatcherFileListRequest() const
return response;
}
+LLSD LLScriptEditorWSServer::handleObjectRequest(U32 connection_id, const LLSD& params)
+{
+ LLUUID object_id = params["object_id"].asUUID();
+ LLSD response;
+
+ if (object_id.isNull())
+ {
+ response["success"] = false;
+ response["message"] = "No object_id specified";
+ return response;
+ }
+
+ LLViewerObject* object = gObjectList.findObject(object_id);
+ if (!object)
+ {
+ response["success"] = false;
+ response["message"] = "Object not found";
+ return response;
+ }
+
+ if (!object->permModify())
+ {
+ response["success"] = false;
+ response["message"] = "Permission denied";
+ return response;
+ }
+
+ bool accepted = publishObject(object_id);
+ response["success"] = accepted;
+ if (!accepted)
+ {
+ response["message"] = "Failed to initiate publish";
+ }
+ return response;
+}
+
+LLScriptEditorWSServer::ValidatedItem LLScriptEditorWSServer::validatePublishedItem(
+ const LLSD& params, U32 permMask) const
+{
+ LLUUID prim_id = params["prim_id"].asUUID();
+ LLUUID item_id = params["item_id"].asUUID();
+
+ if (prim_id.isNull() || item_id.isNull())
+ throw LLJSONRPCConnection::InvalidParams("prim_id and item_id are required");
+
+ LLViewerObject* prim = gObjectList.findObject(prim_id);
+ if (!prim)
+ throw LLJSONRPCConnection::InvalidParams("Prim not found");
+
+ LLViewerObject* root = prim->getRootEdit();
+ if (!root || !isObjectPublished(root->getID()))
+ throw LLJSONRPCConnection::ForbiddenError("Object is not published");
+
+ LLInventoryItem* item = dynamic_cast<LLInventoryItem*>(prim->getInventoryObject(item_id));
+ if (!item)
+ throw LLJSONRPCConnection::InvalidParams("Item not found in prim inventory");
+
+ LLAssetType::EType type = item->getType();
+ if (type != LLAssetType::AT_LSL_TEXT && type != LLAssetType::AT_NOTECARD)
+ throw LLJSONRPCConnection::InvalidParams("Item is not a script or notecard");
+
+ if ((permMask & PERM_COPY) &&
+ !gAgent.allowOperation(PERM_COPY, item->getPermissions(), GP_OBJECT_MANIPULATE))
+ throw LLJSONRPCConnection::ForbiddenError("Insufficient permissions");
+
+ if (permMask & PERM_MODIFY)
+ {
+ // Writes into task inventory require modify permission on both the
+ // item AND the containing prim. A no-mod object can be published
+ // (read-only), but its contents cannot be changed.
+ if (!gAgent.allowOperation(PERM_MODIFY, item->getPermissions(), GP_OBJECT_MANIPULATE))
+ throw LLJSONRPCConnection::ForbiddenError("Insufficient permissions");
+
+ if (!prim->permModify())
+ throw LLJSONRPCConnection::ForbiddenError("No modify permission on object");
+ }
+
+ return { prim, root, item, type };
+}
+
+LLSD LLScriptEditorWSServer::handleObjectContentGet(const std::string& method, const LLSD& id, const LLSD& params)
+{
+ // Permission policy for reading item contents:
+ // - Scripts: require both PERM_COPY and PERM_MODIFY. No-copy or
+ // no-modify scripts cannot have their source exposed.
+ // - Notecards: no permission requirement -- no-mod notecards remain
+ // readable so external editors can view their contents.
+ U32 required_perms = 0;
+ {
+ LLUUID prim_id_peek = params["prim_id"].asUUID();
+ LLUUID item_id_peek = params["item_id"].asUUID();
+ LLViewerObject* prim_peek = gObjectList.findObject(prim_id_peek);
+ if (prim_peek)
+ {
+ if (auto* it = dynamic_cast<LLInventoryItem*>(prim_peek->getInventoryObject(item_id_peek)))
+ {
+ if (it->getType() == LLAssetType::AT_LSL_TEXT)
+ required_perms = PERM_COPY | PERM_MODIFY;
+ }
+ }
+ }
+
+ auto v = validatePublishedItem(params, required_perms);
+
+ LLUUID prim_id = params["prim_id"].asUUID();
+ LLUUID item_id = params["item_id"].asUUID();
+
+ LLSD cb_result = await_async_result(
+ "objectContentGet", ASSET_FETCH_TIMEOUT, "Asset fetch timed out",
+ [&](const std::string& pump_name)
+ {
+ gAssetStorage->getInvItemAsset(
+ v.prim->getRegion()->getHost(),
+ gAgent.getID(),
+ gAgent.getSessionID(),
+ v.item->getPermissions().getOwner(),
+ v.prim->getID(),
+ v.item->getUUID(),
+ v.item->getAssetUUID(),
+ v.type,
+ [pump_name](const LLUUID& asset_uuid, LLAssetType::EType asset_type, void*, S32 status, LLExtStat)
+ {
+ LLSD result;
+ if (status == LL_ERR_NOERR)
+ {
+ result["asset_uuid"] = asset_uuid;
+ result["asset_type"] = static_cast<S32>(asset_type);
+ }
+ else
+ {
+ result["error"] = status;
+ }
+ LLEventPumps::instance().post(pump_name, result);
+ },
+ nullptr,
+ true);
+ });
+
+ if (cb_result.has("error"))
+ {
+ S32 status = cb_result["error"].asInteger();
+ if (status == LL_ERR_ASSET_REQUEST_NOT_IN_DATABASE || status == LL_ERR_FILE_EMPTY)
+ throw LLJSONRPCConnection::InvalidParams("Asset not found");
+ if (status == LL_ERR_INSUFFICIENT_PERMISSIONS)
+ throw LLJSONRPCConnection::ForbiddenError("Insufficient permissions to read asset");
+ throw LLJSONRPCConnection::InternalError("Asset fetch failed: " + std::to_string(status));
+ }
+
+ LLUUID asset_uuid = cb_result["asset_uuid"].asUUID();
+ LLAssetType::EType asset_type = static_cast<LLAssetType::EType>(cb_result["asset_type"].asInteger());
+
+ LLFileSystem file(asset_uuid, asset_type);
+ S32 file_length = file.getSize();
+ if (file_length <= 0)
+ throw LLJSONRPCConnection::InternalError("Asset file empty or not found in cache");
+
+ std::vector<char> buffer(file_length + 1);
+ file.read(reinterpret_cast<U8*>(buffer.data()), file_length);
+ buffer[file_length] = '\0';
+
+ std::string text_content;
+ if (asset_type == LLAssetType::AT_NOTECARD)
+ {
+ // Notecards are stored in an envelope format -- use LLNotecard to extract the text
+ LLNotecard notecard;
+ std::istringstream istr(std::string(buffer.data(), file_length));
+ if (notecard.importStream(istr))
+ {
+ text_content = notecard.getText();
+ }
+ else
+ {
+ throw LLJSONRPCConnection::InternalError("Failed to parse notecard format");
+ }
+ }
+ else
+ {
+ text_content = std::string(buffer.data());
+ }
+
+ LLSD response;
+ response["success"] = true;
+ response["prim_id"] = prim_id;
+ response["item_id"] = item_id;
+ response["content"] = text_content;
+ return response;
+}
+
+LLSD LLScriptEditorWSServer::handleObjectContentSave(const std::string& method, const LLSD& id, const LLSD& params)
+{
+ std::string content = params["content"].asString();
+ if (content.empty())
+ throw LLJSONRPCConnection::InvalidParams("content is required");
+
+ auto v = validatePublishedItem(params, PERM_MODIFY);
+
+ if (v.type == LLAssetType::AT_LSL_TEXT)
+ {
+ return saveScript(v.prim, v.item, content, params);
+ }
+ else
+ {
+ return saveNotecard(v.prim, v.item, content);
+ }
+}
+
+LLSD LLScriptEditorWSServer::saveScript(LLViewerObject* prim, LLInventoryItem* item,
+ const std::string& content, const LLSD& params)
+{
+ // Determine compile target
+ std::string compile_target;
+ if (params.has("vm"))
+ {
+ compile_target = params["vm"].asString();
+ // The client sends "luau" for the Luau VM -- but if the script is LSL
+ // (not native Luau), the internal compile target is "lsl-luau".
+ if (compile_target == "luau" && item->getInventorySubType() != SST_LUA)
+ {
+ compile_target = "lsl-luau";
+ }
+ }
+ else
+ {
+ U8 subtype = item->getInventorySubType();
+ std::string runtime = item->getRuntime();
+ bool is_lua = (subtype == SST_LUA);
+ if (!is_lua && runtime == "luau")
+ compile_target = "lsl-luau";
+ else if (!runtime.empty())
+ compile_target = runtime;
+ else
+ {
+ is_lua = is_lua_script(content);
+ compile_target = is_lua ? "luau" : "mono";
+ }
+ }
+
+ std::string url = prim->getRegion()->getCapability("UpdateScriptTask");
+ if (url.empty())
+ throw LLJSONRPCConnection::InternalError("UpdateScriptTask capability not available");
+
+ LLSD cb_result = await_async_result(
+ "objectContentSave", SCRIPT_UPLOAD_TIMEOUT, "Script upload/compile timed out",
+ [&](const std::string& pump_name)
+ {
+ auto [on_success, on_failure] = make_asset_upload_callbacks(pump_name);
+ bool is_running = params.has("running") ? params["running"].asBoolean() : false;
+ LLResourceUploadInfo::ptr_t uploadInfo(std::make_shared<LLScriptAssetUpload>(
+ prim->getID(), item->getUUID(),
+ compile_target, is_running, LLUUID::null, content,
+ std::move(on_success), std::move(on_failure)));
+ LLViewerAssetUpload::EnqueueInventoryUpload(url, uploadInfo);
+ });
+
+ if (cb_result.has("failed"))
+ throw LLJSONRPCConnection::InternalError("Upload failed: " + cb_result["reason"].asString());
+
+ LLSD response;
+ response["success"] = true;
+ response["prim_id"] = prim->getID();
+ response["item_id"] = item->getUUID();
+ response["compiled"] = cb_result["compiled"];
+ if (!cb_result["compiled"].asBoolean() && cb_result.has("errors"))
+ {
+ response["errors"] = cb_result["errors"];
+ }
+
+ // If the script is open in the viewer's editor, update it
+ LLSD floater_key;
+ floater_key["taskid"] = prim->getID();
+ floater_key["itemid"] = item->getUUID();
+ LLLiveLSLEditor* editor = LLFloaterReg::findTypedInstance<LLLiveLSLEditor>("preview_scriptedit", floater_key);
+ if (editor)
+ {
+ LLScriptEdCore* sed = editor->getScriptEdCore();
+ if (sed)
+ {
+ sed->setScriptText(LLStringExplicit(content), true);
+ sed->makeEditorPristine();
+ }
+ }
+
+ return response;
+}
+
+LLSD LLScriptEditorWSServer::saveNotecard(LLViewerObject* prim, LLInventoryItem* item,
+ const std::string& content)
+{
+ std::string url = prim->getRegion()->getCapability("UpdateNotecardTaskInventory");
+ if (url.empty())
+ throw LLJSONRPCConnection::InternalError("UpdateNotecardTaskInventory capability not available");
+
+ // Use LLNotecard to produce the proper notecard format
+ LLNotecard notecard;
+ notecard.setText(content);
+
+ std::ostringstream ostr;
+ notecard.exportStream(ostr);
+
+ LLSD cb_result = await_async_result(
+ "objectContentSaveNotecard", NOTECARD_UPLOAD_TIMEOUT, "Notecard upload timed out",
+ [&](const std::string& pump_name)
+ {
+ auto [on_success, on_failure] = make_asset_upload_callbacks(pump_name);
+ LLResourceUploadInfo::ptr_t uploadInfo(std::make_shared<LLBufferedAssetUploadInfo>(
+ prim->getID(), item->getUUID(),
+ LLAssetType::AT_NOTECARD, ostr.str(),
+ std::move(on_success), std::move(on_failure)));
+ LLViewerAssetUpload::EnqueueInventoryUpload(url, uploadInfo);
+ });
+
+ if (cb_result.has("failed"))
+ throw LLJSONRPCConnection::InternalError("Upload failed: " + cb_result["reason"].asString());
+
+ LLSD response;
+ response["success"] = true;
+ response["prim_id"] = prim->getID();
+ response["item_id"] = item->getUUID();
+
+ // If the notecard is open in the viewer's editor, update it
+ LLSD floater_key;
+ floater_key["taskid"] = prim->getID();
+ floater_key["itemid"] = item->getUUID();
+ LLPreviewNotecard* nc = LLFloaterReg::findTypedInstance<LLPreviewNotecard>("preview_notecard", floater_key);
+ if (nc)
+ {
+ LLViewerTextEditor* nc_editor = nc->getChild<LLViewerTextEditor>("Notecard Editor");
+ if (nc_editor)
+ {
+ nc_editor->setText(content);
+ nc_editor->makePristine();
+ }
+ }
+
+ return response;
+}
+
+LLSD LLScriptEditorWSServer::handleObjectItemDelete(U32 connection_id, const LLSD& params)
+{
+ auto v = validatePublishedItem(params, PERM_MODIFY);
+
+ v.prim->removeInventory(v.item->getUUID());
+
+ LLSD response;
+ response["success"] = true;
+ response["prim_id"] = params["prim_id"].asUUID();
+ response["item_id"] = params["item_id"].asUUID();
+ return response;
+}
+
+LLSD LLScriptEditorWSServer::handleObjectUnpublish(U32 connection_id, const LLSD& params)
+{
+ LLUUID object_id = params["object_id"].asUUID();
+ if (object_id.isNull())
+ throw LLJSONRPCConnection::InvalidParams("object_id is required");
+
+ auto it = mPublishedObjects.find(object_id);
+ if (it == mPublishedObjects.end())
+ throw LLJSONRPCConnection::InvalidParams("Object is not published");
+ unpublishObject(object_id, "manual");
+
+ LLSD response;
+ response["success"] = true;
+ response["object_id"] = object_id;
+ return response;
+}
+
+LLSD LLScriptEditorWSServer::handleObjectItemCreate(const std::string& method, const LLSD& id, const LLSD& params)
+{
+ std::string type = params["type"].asString();
+ if (type != "script" && type != "notecard")
+ {
+ throw LLJSONRPCConnection::InvalidParams("Unsupported item type: " + type);
+ }
+
+ LLUUID prim_id = params["prim_id"].asUUID();
+ if (prim_id.isNull())
+ {
+ throw LLJSONRPCConnection::InvalidParams("prim_id is required");
+ }
+
+ LLViewerObject* prim = gObjectList.findObject(prim_id);
+ if (!prim)
+ {
+ throw LLJSONRPCConnection::InvalidParams("Prim not found");
+ }
+
+ LLViewerObject* root = prim->getRootEdit();
+ if (!root || !isObjectPublished(root->getID()))
+ {
+ throw LLJSONRPCConnection::ForbiddenError("Object is not published");
+ }
+
+ std::string name = params["name"].asString();
+ if (name.empty())
+ {
+ throw LLJSONRPCConnection::InvalidParams("name is required");
+ }
+
+ bool has_cap = prim->getRegion() && !prim->getRegion()->getCapability("CreateTaskInventoryItem").empty();
+
+ if (type == "notecard" && !has_cap)
+ {
+ throw LLJSONRPCConnection::ForbiddenError("Notecard creation requires CreateTaskInventoryItem capability");
+ }
+
+ // Resolve type-specific fields
+ LLAssetType::EType asset_type;
+ LLInventoryType::EType inv_type;
+ U8 sub_type = 0;
+ const char* perm_key;
+ LLSD cap_params;
+
+ if (type == "script")
+ {
+ std::string vm = params["vm"].asString();
+ if (vm == "luau")
+ {
+ sub_type = SST_LUA;
+ }
+ else if (vm == "mono" || vm == "lsl2")
+ {
+ sub_type = SST_LSL;
+ }
+ else
+ {
+ throw LLJSONRPCConnection::InvalidParams("vm must be 'luau', 'mono', or 'lsl2'");
+ }
+
+ asset_type = LLAssetType::AT_LSL_TEXT;
+ inv_type = LLInventoryType::IT_LSL;
+ perm_key = "Scripts";
+ cap_params["enabled"] = true;
+ cap_params["vm"] = vm;
+ }
+ else
+ {
+ asset_type = LLAssetType::AT_NOTECARD;
+ inv_type = LLInventoryType::IT_NOTECARD;
+ perm_key = "Notecards";
+ if (params.has("text"))
+ {
+ cap_params["text"] = params["text"].asString();
+ }
+ }
+
+ LLPermissions perms;
+ perms.init(gAgent.getID(), gAgent.getID(), LLUUID::null, LLUUID::null);
+ perms.initMasks(
+ PERM_ALL,
+ PERM_ALL,
+ LLFloaterPerms::getEveryonePerms(perm_key),
+ LLFloaterPerms::getGroupPerms(perm_key),
+ PERM_MOVE | LLFloaterPerms::getNextOwnerPerms(perm_key));
+
+ std::string desc;
+ LLViewerAssetType::generateDescriptionFor(asset_type, desc);
+
+ // Snapshot existing item IDs before creation
+ std::set<LLUUID> existing_items;
+ {
+ LLInventoryObject::object_list_t inv;
+ prim->getInventoryContents(inv);
+ for (auto& obj : inv)
+ {
+ existing_items.insert(obj->getUUID());
+ }
+ }
+
+ // Reject if another item.create is already in flight for this prim; the
+ // map keys by prim, so two concurrent creates would clobber one another.
+ if (mPendingItemCreates.find(prim_id) != mPendingItemCreates.end())
+ {
+ throw LLJSONRPCConnection::InvalidRequest(
+ "An item.create is already in flight for this prim");
+ }
+
+ // Set up event pump to wait for inventory change
+ LLEventMailDrop result_pump("objectItemCreate." + LLUUID::generateNewID().asString(), true);
+ mPendingItemCreates[prim_id] = result_pump.getName();
+
+ // RAII: guarantee the pending entry is cleared on every exit path (throw
+ // or normal return), so no exception between here and the erase-on-post
+ // in onPrimInventoryChanged can leave a stale entry behind. Uses a
+ // shared_ptr custom deleter as a lightweight scope guard.
+ std::shared_ptr<void> pending_guard(nullptr, [this, prim_id](void*)
+ {
+ mPendingItemCreates.erase(prim_id);
+ });
+
+ if (has_cap)
+ {
+ prim->createInventoryItem(asset_type, inv_type, sub_type, name, desc, perms, cap_params,
+ [pump_name = result_pump.getName()](bool success, const LLSD& response)
+ {
+ LLEventPumps::instance().obtain(pump_name).post(response);
+ });
+ }
+ else
+ {
+ // Fallback: legacy RezScript UDP (scripts only — notecards already rejected above)
+ LLPointer<LLViewerInventoryItem> new_item =
+ new LLViewerInventoryItem(
+ LLUUID::null, LLUUID::null, perms, LLUUID::null,
+ asset_type, inv_type, name, desc, LLSaleInfo::DEFAULT,
+ LLInventoryItemFlags::II_FLAGS_SUBTYPE_MASK & sub_type,
+ time_corrected());
+ prim->saveScript(new_item, true, true, LLUUID::null);
+ }
+
+ // Wait for inventory change callback
+ LLSD event = llcoro::suspendUntilEventOnWithTimeout(result_pump, ITEM_CREATE_TIMEOUT, LLSD().with("timeout", true));
+
+ if (event.has("timeout"))
+ {
+ throw LLJSONRPCConnection::RequestTimeoutError("Timed out waiting for item creation");
+ }
+
+ prim = gObjectList.findObject(prim_id);
+ if (!prim)
+ {
+ throw LLJSONRPCConnection::InternalError("Prim no longer exists");
+ }
+
+ LLSD response;
+
+ // If cap returned item_id directly, use it
+ if (event.has("success") && event["success"].asBoolean() &&
+ event.has("item_id") && event["item_id"].asUUID().notNull())
+ {
+ response["item_id"] = event["item_id"];
+ response["name"] = event["name"];
+ response["description"] = desc;
+ response["type"] = type;
+ response["prim_id"] = prim_id;
+
+ if (type == "script")
+ {
+ response["subtype"] = static_cast<S32>(sub_type);
+ }
+
+ LLSD perm_entry;
+ perm_entry["owner"] = static_cast<S32>(perms.getMaskOwner());
+ perm_entry["next_owner"] = static_cast<S32>(perms.getMaskNextOwner());
+ response["permissions"] = perm_entry;
+ response["creator_id"] = gAgent.getID();
+ }
+ else
+ {
+ // Fallback: search inventory (for UDP path or if cap didn't return item_id)
+ LLInventoryObject::object_list_t inv;
+ prim->getInventoryContents(inv);
+ for (auto& obj : inv)
+ {
+ if (existing_items.find(obj->getUUID()) == existing_items.end())
+ {
+ LLInventoryItem* created = dynamic_cast<LLInventoryItem*>(obj.get());
+ if (created && created->getType() == asset_type)
+ {
+ response["item_id"] = created->getUUID();
+ response["name"] = created->getName();
+ response["description"] = created->getDescription();
+ response["type"] = type;
+
+ if (type == "script")
+ {
+ response["subtype"] = static_cast<S32>(created->getInventorySubType());
+ const std::string& runtime = created->getRuntime();
+ if (!runtime.empty())
+ {
+ response["vm"] = runtime;
+ }
+ }
+
+ const LLPermissions& item_perms = created->getPermissions();
+ LLSD perm_entry;
+ perm_entry["owner"] = static_cast<S32>(item_perms.getMaskOwner());
+ perm_entry["next_owner"] = static_cast<S32>(item_perms.getMaskNextOwner());
+ response["permissions"] = perm_entry;
+ response["creator_id"] = item_perms.getCreator();
+ response["prim_id"] = prim_id;
+ break;
+ }
+ }
+ }
+ }
+
+ if (!response.has("item_id"))
+ {
+ throw LLJSONRPCConnection::InternalError("Item was not found in updated inventory");
+ }
+
+ return response;
+}
+
+
void LLScriptEditorWSServer::notifyScript(const std::string& script_id, const std::string &method, const LLSD& message) const
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
auto it = mSubscriptions.find(script_id);
if (it != mSubscriptions.end())
{
@@ -541,6 +1773,7 @@ void LLScriptEditorWSServer::notifyScript(const std::string& script_id, const st
void LLScriptEditorWSServer::sendUnsubscribeScriptEditor(const std::string& script_id)
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
LLSD params;
params["script_id"] = script_id;
@@ -549,6 +1782,7 @@ void LLScriptEditorWSServer::sendUnsubscribeScriptEditor(const std::string& scri
void LLScriptEditorWSServer::sendCompileResults(const std::string &script_id, const LLSD &results) const
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
LLHandle<LLPanel> editor_handle = findEditorForScript(script_id);
if (editor_handle.isDead())
{
@@ -638,6 +1872,7 @@ void LLScriptEditorWSServer::sendCompileResults(const std::string &script_id, co
void LLScriptEditorWSServer::forwardChatToIDE(const LLChat& chat_msg) const
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
auto it = std::find_if(mSubscriptions.begin(), mSubscriptions.end(),
[&chat_msg](const auto& pair) { return (pair.second.mObjectID == chat_msg.mFromID); });
@@ -656,7 +1891,12 @@ void LLScriptEditorWSServer::forwardChatToIDE(const LLChat& chat_msg) const
std::vector<std::string> lines = LLStringUtil::getTokens(chat_msg.mText, "\n");
// If this is a runtime error, the first line will look like: "<Object Name> [script:<Script Name>] Script run-time error"
static const std::string runtime_error_marker = "Script run-time error";
- if (!lines.empty() && std::equal(runtime_error_marker.rbegin(), runtime_error_marker.rend(), lines.front().rbegin()))
+ auto ends_with = [](const std::string& s, const std::string& suffix)
+ {
+ return s.size() >= suffix.size() &&
+ std::equal(suffix.rbegin(), suffix.rend(), s.rbegin());
+ };
+ if (!lines.empty() && ends_with(lines.front(), runtime_error_marker))
{
is_error = true;
std::string first_line = lines.front();
@@ -673,46 +1913,13 @@ void LLScriptEditorWSServer::forwardChatToIDE(const LLChat& chat_msg) const
remove_count++;
}
- // TODO: Build an actual error message to forward to the external editor
- // Explaination:
- // Well! Heck!
- // As it turns out, the complete error message arrives as either two or three
- // separate chat messages from the server.
- // 2 if the script is LSL or if it is Lua but not owned by the editing agent
- // 3 if the script is Lua and owned by the editing agent.
- //
- // Message 1: <Object Name> [script:<Script Name>] Script run-time error
- // Message 2: <runtime error>
- // Message 3: <script>:<line>: <actual error message>\n
- // <call stack>
- //
- // These need to be compositited into a single error message to send to the IDE.
- //
- //if (lines.size() > 1)
- //{ // The second line is the actual error message
- // error_message = lines[1];
- // remove_count++;
- // if ((error_message == "runtime error") && (lines.size() > 2))
- // { // If the error message is just "runtime error", the next line might actually be the real message:
- // // "lua_script:7: attempt to perform arithmetic (sub) on nil"
- // static const boost::regex LUA_ERROR_REGEX(R"(^(.+?):(\d+):\s*(.+)$)");
- //
- // if (boost::regex_match(first_line, m, RUNTIME_ERR_REGEX_FLEX))
- // {
- // line_number = std::stoi(m[2].str());
- // error_message = m[3].str();
- // remove_count++;
- // }
- // }
- // else
- // {
- // error_message = "Unknown script runtime error";
- // }
- //}
- //else
- //{
- // error_message = "Unknown script runtime error";
- //}
+ // TODO: Build an actual error message to forward to the external editor.
+ // The complete error message arrives as two or three separate chat
+ // messages from the server (2 for LSL / non-owner Lua, 3 for owner Lua):
+ // Message 1: <Object Name> [script:<Script Name>] Script run-time error
+ // Message 2: <runtime error>
+ // Message 3: <script>:<line>: <actual error message>\n<call stack>
+ // These need to be composited into a single error message for the IDE.
if (lines.size() > remove_count)
{ // The rest of the lines may contain a stack trace
lines.erase(lines.begin(), lines.begin() + remove_count);
@@ -763,8 +1970,619 @@ void LLScriptEditorWSServer::forwardChatToIDE(const LLChat& chat_msg) const
}
}
+void LLScriptEditorWSServer::notifyConnection(U32 connection_id, const std::string& method, const LLSD& params) const
+{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
+ auto it = mActiveConnections.find(connection_id);
+ if (it != mActiveConnections.end())
+ {
+ auto connection = it->second.lock();
+ if (connection)
+ {
+ connection->notify(method, params);
+ }
+ }
+}
+
+void LLScriptEditorWSServer::notifyAll(const std::string& method, const LLSD& params) const
+{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
+ // Serialize once, deliver many: build the JSON-RPC envelope and its wire
+ // string a single time, then hand the bytes to each connection.
+ LLSD envelope = LLJSONRPCConnection::makeEnvelope(
+ LLSD(), method, params, LLSD(), LLSD());
+ std::string payload = boost::json::serialize(LlsdToJson(envelope));
+
+ for (const auto& pair : mActiveConnections)
+ {
+ auto connection = pair.second.lock();
+ if (connection)
+ {
+ connection->sendMessage(payload);
+ }
+ }
+}
+
+
+// static
+LLSD LLScriptEditorWSServer::errorResponse(const std::string& message)
+{
+ LLSD response;
+ response["success"] = false;
+ response["message"] = message;
+ return response;
+}
+
+// static
+std::string LLScriptEditorWSServer::getPrimName(LLViewerObject* obj)
+{
+ std::string name = nv_string(obj, "Name");
+ if (!name.empty())
+ {
+ return name;
+ }
+
+ if (!obj)
+ {
+ return std::string();
+ }
+
+ LLSelectNode* node = LLSelectMgr::instance().getSelection()->findNode(obj);
+ return (node && !node->mName.empty()) ? node->mName : std::string();
+}
+
+LLSD LLScriptEditorWSServer::buildPrimInventoryLLSD(LLViewerObject* object) const
+{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
+ LLSD items = LLSD::emptyArray();
+ if (!object) return items;
+
+ LLInventoryObject::object_list_t contents;
+ object->getInventoryContents(contents);
+
+ for (const auto& obj : contents)
+ {
+ LLInventoryItem* item = dynamic_cast<LLInventoryItem*>(obj.get());
+ if (!item) continue;
+
+ LLAssetType::EType type = item->getType();
+
+ // Filter: only scripts and notecards
+ if (type != LLAssetType::AT_LSL_TEXT && type != LLAssetType::AT_NOTECARD)
+ {
+ continue;
+ }
+
+ LLSD entry;
+ entry["item_id"] = item->getUUID();
+ entry["name"] = item->getName();
+ entry["description"] = item->getDescription();
+ entry["type"] = (type == LLAssetType::AT_LSL_TEXT) ? "script" : "notecard";
+
+ if (type == LLAssetType::AT_LSL_TEXT)
+ {
+ U8 subtype = item->getInventorySubType();
+ entry["subtype"] = static_cast<S32>(subtype); // 0=LSL, 1=Luau
+
+ const std::string& runtime = item->getRuntime();
+ if (!runtime.empty())
+ {
+ entry["vm"] = runtime;
+ }
+
+ // Script runtime state from task inventory cap
+ LLViewerInventoryItem* viewer_item = dynamic_cast<LLViewerInventoryItem*>(item);
+ if (viewer_item)
+ {
+ entry["running"] = viewer_item->getIsRunning();
+ entry["faulted"] = viewer_item->getIsFaulted();
+ }
+ }
+
+ // Permissions
+ const LLPermissions& perms = item->getPermissions();
+ LLSD perm_entry;
+ perm_entry["owner"] = static_cast<S32>(perms.getMaskOwner());
+ perm_entry["next_owner"] = static_cast<S32>(perms.getMaskNextOwner());
+ entry["permissions"] = perm_entry;
+
+ entry["creator_id"] = perms.getCreator();
+
+ items.append(entry);
+ }
+
+ return items;
+}
+
+bool LLScriptEditorWSServer::publishObject(const LLUUID& object_id)
+{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
+ LLViewerObject* root = gObjectList.findObject(object_id);
+ if (!root)
+ {
+ LL_WARNS("ScriptEditorWS") << "publishObject: object not found: " << object_id << LL_ENDL;
+ return false;
+ }
+
+ if (!root->permModify())
+ {
+ LL_WARNS("ScriptEditorWS") << "publishObject: no modify permission on object: " << object_id << LL_ENDL;
+ return false;
+ }
+
+ // If already published, unpublish first to replace cleanly
+ if (isObjectPublished(object_id))
+ {
+ unpublishObject(object_id, "republish");
+ }
+
+ // Collect root + all children
+ std::vector<LLViewerObject*> prims = collect_linkset(root);
+
+ // Set up a PendingPublish to coordinate inventory loading across all prims.
+ // We register a listener and call requestInventory() on every prim.
+ // If inventory is already loaded, requestInventory() fires the callback
+ // synchronously via doInventoryCallback(), so all_ready will naturally
+ // become true before this function returns in the common case.
+ PendingPublish pending;
+ pending.mObjectID = object_id;
+
+ for (LLViewerObject* prim : prims)
+ {
+ pending.mPendingPrims.insert(prim->getID());
+ auto listener = std::make_unique<LLPublishedPrimListener>(
+ this, object_id, prim->getID(), prim);
+ pending.mListeners.push_back(std::move(listener));
+ }
+
+ mPendingPublishes[object_id] = std::move(pending);
+
+ // Request inventory for each prim. If already loaded, onPrimInventoryReady()
+ // will be called immediately (possibly building and sending the publish
+ // before this loop even finishes).
+ for (LLViewerObject* prim : prims)
+ {
+ if (mPendingPublishes.find(object_id) == mPendingPublishes.end())
+ {
+ break; // publish completed synchronously during a previous iteration
+ }
+ prim->requestInventory();
+ }
+
+ return true;
+}
+
+bool LLScriptEditorWSServer::isObjectPublished(const LLUUID& object_id) const
+{
+ return mPublishedObjects.find(object_id) != mPublishedObjects.end();
+}
+
+void LLScriptEditorWSServer::onPrimInventoryReady(const LLUUID& object_id, const LLUUID& prim_id)
+{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
+ auto it = mPendingPublishes.find(object_id);
+ if (it == mPendingPublishes.end()) return;
+
+ it->second.mPendingPrims.erase(prim_id);
+
+ if (it->second.mPendingPrims.empty())
+ {
+ LL_DEBUGS("ScriptEditorWS") << "All prim inventories ready for object " << object_id << LL_ENDL;
+ buildAndSendPublish(object_id);
+ }
+}
+
+LLSD LLScriptEditorWSServer::buildPublishedObjectLLSD(LLViewerObject* root) const
+{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
+ LLSD pub;
+ pub["object_id"] = root->getID();
+ pub["object_name"] = getPrimName(root);
+ pub["object_description"] = nv_string(root, "Desc");
+ pub["owner_id"] = root->mOwnerID;
+ if (root->getRegion())
+ {
+ pub["region"] = root->getRegion()->getName();
+ }
+ pub["inventory"] = buildPrimInventoryLLSD(root);
+
+ LLSD linked_objects = LLSD::emptyArray();
+ S32 link_number = 2;
+ for (LLViewerObject* child : root->getChildren())
+ {
+ LLSD link;
+ link["link_id"] = child->getID();
+ link["link_number"] = link_number++;
+ link["link_name"] = getPrimName(child);
+ link["link_description"] = nv_string(child, "Desc");
+ link["inventory"] = buildPrimInventoryLLSD(child);
+ linked_objects.append(link);
+ }
+ if (linked_objects.size() > 0)
+ {
+ pub["linked_objects"] = linked_objects;
+ }
+
+ return pub;
+}
+
+void LLScriptEditorWSServer::buildAndSendPublish(const LLUUID& object_id)
+{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
+ auto pending_it = mPendingPublishes.find(object_id);
+ if (pending_it == mPendingPublishes.end())
+ {
+ LL_WARNS("ScriptEditorWS") << "buildAndSendPublish: no pending publish for " << object_id << LL_ENDL;
+ return;
+ }
+
+ LLViewerObject* root = gObjectList.findObject(object_id);
+ if (!root)
+ {
+ LL_WARNS("ScriptEditorWS") << "buildAndSendPublish: root object gone: " << object_id << LL_ENDL;
+ mPendingPublishes.erase(pending_it);
+ return;
+ }
+
+ LLSD pub = buildPublishedObjectLLSD(root);
+
+ // Store in the published registry
+ PublishedObjectInfo info;
+ info.mObjectID = root->getID();
+ info.mOwnerID = root->mOwnerID;
+ info.mObjectName = pub["object_name"].asString();
+ info.mObjectDescription = pub["object_description"].asString();
+ if (root->getRegion())
+ {
+ info.mRegionName = root->getRegion()->getName();
+ }
+
+ S32 link_num = 1;
+ std::vector<LLViewerObject*> prims = collect_linkset(root);
+ for (LLViewerObject* prim : prims)
+ {
+ PublishedPrimInfo prim_info;
+ prim_info.mPrimID = prim->getID();
+ prim_info.mPrimName = getPrimName(prim); // Use helper with selection fallback
+ prim_info.mLinkNumber = link_num++;
+ prim_info.mInventorySerial = static_cast<S16>(prim->getInventorySerial());
+ info.mPrims.push_back(prim_info);
+ }
+
+ mPublishedObjects[object_id] = std::move(info);
+ mPublishedObjects[object_id].mListeners = std::move(pending_it->second.mListeners);
+ mPendingPublishes.erase(pending_it);
+
+ // Send notification
+ LLSD message;
+ message["object"] = pub;
+ notifyAll("object.publish", message);
+
+ LL_INFOS("ScriptEditorWS") << "Published object " << object_id
+ << " (" << pub["object_name"].asString() << ") with "
+ << (prims.size() - 1) << " linked prim(s)" << LL_ENDL;
+}
+
+void LLScriptEditorWSServer::onLinksetChildAdded(const LLUUID& root_id, LLViewerObject* child)
+{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
+ auto obj_it = mPublishedObjects.find(root_id);
+ if (obj_it == mPublishedObjects.end()) return;
+
+ const LLUUID child_id = child->getID();
+ PublishedObjectInfo& info = obj_it->second;
+
+ // Add a placeholder slot so the flush can enumerate the full linkset
+ // even before inventory arrives. flushLinksetUpdate renumbers from the
+ // live mPrims list, so the tentative link_number here is just informational.
+ PublishedPrimInfo prim_info;
+ prim_info.mPrimID = child_id;
+ prim_info.mPrimName = getPrimName(child);
+ prim_info.mLinkNumber = static_cast<S32>(info.mPrims.size()) + 1;
+ prim_info.mInventorySerial = -1; // sentinel: not yet loaded
+ info.mPrims.push_back(prim_info);
+
+ // Register a listener so we are notified when the child's inventory arrives.
+ // The listener constructor calls registerVOInventoryListener internally.
+ auto listener = std::make_unique<LLPublishedPrimListener>(this, root_id, child_id, child);
+ info.mListeners.push_back(std::move(listener));
+
+ // Request inventory (async; fires onPrimInventoryChanged when ready)
+ child->requestInventory();
+
+ // Mark as pending — the flush waits until this is cleared
+ mNewChildPrims[root_id].insert(child_id);
+
+ // Start safety-timeout timer (no-op if one is already pending for this root)
+ scheduleLinksetFlush(root_id, LINKSET_ADD_FLUSH_DELAY);
+}
+
+void LLScriptEditorWSServer::onLinksetChildRemoved(const LLUUID& root_id, const LLUUID& child_id)
+{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
+ auto obj_it = mPublishedObjects.find(root_id);
+ if (obj_it == mPublishedObjects.end()) return;
+
+ PublishedObjectInfo& info = obj_it->second;
+
+ // Remove prim slot
+ info.mPrims.erase(
+ std::remove_if(info.mPrims.begin(), info.mPrims.end(),
+ [&](const PublishedPrimInfo& p) { return p.mPrimID == child_id; }),
+ info.mPrims.end());
+
+ // Destroy the prim's inventory listener
+ info.mListeners.erase(
+ std::remove_if(info.mListeners.begin(), info.mListeners.end(),
+ [&](const std::unique_ptr<LLPublishedPrimListener>& l)
+ { return l->getPrimID() == child_id; }),
+ info.mListeners.end());
+
+ // Remove from pending-inventory set (child may have been added then removed
+ // before its inventory ever arrived)
+ auto nc_it = mNewChildPrims.find(root_id);
+ if (nc_it != mNewChildPrims.end())
+ {
+ nc_it->second.erase(child_id);
+ if (nc_it->second.empty())
+ mNewChildPrims.erase(nc_it);
+ }
+
+ // Re-number remaining children (root stays 1, children get 2..N in order)
+ S32 link_num = 2;
+ for (auto& p : info.mPrims)
+ {
+ if (p.mPrimID != root_id)
+ p.mLinkNumber = link_num++;
+ }
+
+ // Schedule coalesced flush — multiple simultaneous removes share one timer
+ scheduleLinksetFlush(root_id, LINKSET_REMOVE_FLUSH_DELAY);
+}
+
+void LLScriptEditorWSServer::scheduleLinksetFlush(const LLUUID& root_id, F32 delay)
+{
+ // No-op if a timer is already pending for this root_id
+ auto it = mLinksetFlushTimers.find(root_id);
+ if (it != mLinksetFlushTimers.end() && !it->second.expired())
+ return;
+
+ wptr_t weak = std::static_pointer_cast<LLScriptEditorWSServer>(shared_from_this());
+ LLEventTimer* t = LLEventTimer::run_after(delay, [weak, root_id]()
+ {
+ if (auto self = weak.lock())
+ {
+ self->mLinksetFlushTimers.erase(root_id);
+ self->mNewChildPrims.erase(root_id); // clear any remaining pending children (timeout path)
+ self->flushLinksetUpdate(root_id);
+ }
+ });
+ mLinksetFlushTimers[root_id] = t->getWeak();
+}
+
+void LLScriptEditorWSServer::cancelLinksetFlushTimer(const LLUUID& root_id)
+{
+ auto it = mLinksetFlushTimers.find(root_id);
+ if (it == mLinksetFlushTimers.end())
+ return;
+ if (auto locked = it->second.lock())
+ {
+ // LLEventTimer contract (see lleventtimer.h): the shared_ptr held by
+ // LLInstanceTracker uses a no-op deleter, so this raw delete is safe
+ // and is the documented way to cancel a pending timer.
+ delete locked.get();
+ }
+ mLinksetFlushTimers.erase(it);
+}
+
+void LLScriptEditorWSServer::flushLinksetUpdate(const LLUUID& root_id)
+{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
+ auto obj_it = mPublishedObjects.find(root_id);
+ if (obj_it == mPublishedObjects.end()) return;
+
+ const PublishedObjectInfo& info = obj_it->second;
+
+ // Build full linked_objects replacement (children only, in link_number order)
+ LLSD linked_objects(LLSD::TypeArray);
+ for (const PublishedPrimInfo& prim_info : info.mPrims)
+ {
+ if (prim_info.mPrimID == root_id) continue; // root is not in linked_objects
+
+ LLSD entry;
+ entry["link_id"] = prim_info.mPrimID;
+ entry["link_number"] = prim_info.mLinkNumber;
+
+ LLViewerObject* prim = gObjectList.findObject(prim_info.mPrimID);
+ // Always read the name fresh from the live object so newly-linked prims
+ // whose NV pair was not yet available at addChild time still get a name.
+ std::string link_name = prim ? getPrimName(prim) : std::string();
+ if (link_name.empty()) link_name = prim_info.mPrimName; // fallback to stored name
+ entry["link_name"] = link_name;
+ entry["inventory"] = prim ? buildPrimInventoryLLSD(prim) : LLSD(LLSD::TypeArray);
+
+ linked_objects.append(entry);
+ }
+
+ LLSD update;
+ update["object_id"] = root_id;
+ update["linked_objects"] = linked_objects;
+ notifyAll("object.update", update);
+
+ LL_INFOS("ScriptEditorWS") << "Linkset update for " << root_id
+ << ": " << linked_objects.size() << " child(ren)" << LL_ENDL;
+}
+
+void LLScriptEditorWSServer::onPrimInventoryChanged(const LLUUID& object_id, const LLUUID& prim_id)
+{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
+ auto pub_it = mPublishedObjects.find(object_id);
+ if (pub_it == mPublishedObjects.end())
+ return;
+
+ LLViewerObject* prim = gObjectList.findObject(prim_id);
+ if (!prim)
+ return;
+
+ // ── New-child path ────────────────────────────────────────────────────
+ // When a child was linked in via onLinksetChildAdded it is placed in
+ // mNewChildPrims until its first inventory response arrives here.
+ auto nc_root_it = mNewChildPrims.find(object_id);
+ if (nc_root_it != mNewChildPrims.end() && nc_root_it->second.count(prim_id))
+ {
+ // Update the placeholder with the real prim name now that we have data
+ for (auto& p : pub_it->second.mPrims)
+ {
+ if (p.mPrimID == prim_id)
+ {
+ p.mPrimName = getPrimName(prim);
+ p.mInventorySerial = 0; // mark as loaded
+ break;
+ }
+ }
+
+ nc_root_it->second.erase(prim_id);
+
+ if (nc_root_it->second.empty())
+ {
+ // All new children have inventory — cancel timeout, flush now
+ mNewChildPrims.erase(nc_root_it);
+ cancelLinksetFlushTimer(object_id);
+ flushLinksetUpdate(object_id);
+ }
+ // else: still waiting for other new children
+ return;
+ }
+ // ── Normal inventory-change path ──────────────────────────────────────
+
+ LLSD update;
+ update["object_id"] = object_id;
+
+ LLSD inv = buildPrimInventoryLLSD(prim);
+ if (prim_id == object_id)
+ {
+ // Root prim — use top-level inventory field (full replacement)
+ update["inventory"] = inv;
+ }
+ else
+ {
+ // Child prim — wrap in changes.linked_objects.modified so the extension
+ // routes the update to the correct linked prim directory
+ LLSD modified_entry;
+ modified_entry["link_id"] = prim_id;
+ modified_entry["inventory"] = inv;
+ LLSD modified_arr = LLSD::emptyArray();
+ modified_arr.append(modified_entry);
+ update["changes"]["linked_objects"]["modified"] = modified_arr;
+ }
+
+ notifyAll("object.update", update);
+
+ // Signal any pending item.create coroutine waiting on this prim
+ auto create_it = mPendingItemCreates.find(prim_id);
+ if (create_it != mPendingItemCreates.end())
+ {
+ LLEventPumps::instance().post(create_it->second, LLSD().with("prim_id", prim_id));
+ mPendingItemCreates.erase(create_it);
+ }
+
+ LL_DEBUGS("ScriptEditorWS") << "Sent object.update for prim " << prim_id
+ << " in object " << object_id << LL_ENDL;
+}
+
+void LLScriptEditorWSServer::onObjectPropertyChanged(
+ const LLUUID& prim_id, const std::string& name, const std::string& desc)
+{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
+ LLViewerObject* prim = gObjectList.findObject(prim_id);
+ if (!prim) return;
+ LLUUID root_id = prim->getRootEdit()->getID();
+
+ auto pub_it = mPublishedObjects.find(root_id);
+ if (pub_it == mPublishedObjects.end()) return;
+
+ LLSD update;
+ update["object_id"] = root_id;
+
+ if (prim_id == root_id)
+ {
+ bool name_changed = (pub_it->second.mObjectName != name);
+ bool desc_changed = (pub_it->second.mObjectDescription != desc);
+ if (!name_changed && !desc_changed) return;
+
+ if (name_changed) { pub_it->second.mObjectName = name; update["object_name"] = name; }
+ if (desc_changed) { pub_it->second.mObjectDescription = desc; update["object_description"] = desc; }
+ }
+ else
+ {
+ auto prim_it = std::find_if(pub_it->second.mPrims.begin(), pub_it->second.mPrims.end(),
+ [&](const PublishedPrimInfo& p) { return p.mPrimID == prim_id; });
+ if (prim_it == pub_it->second.mPrims.end()) return;
+ if (prim_it->mPrimName == name) return;
+
+ prim_it->mPrimName = name;
+ LLSD modified_entry;
+ modified_entry["link_id"] = prim_id;
+ modified_entry["link_name"] = name;
+ LLSD modified_arr = LLSD::emptyArray();
+ modified_arr.append(modified_entry);
+ update["changes"]["linked_objects"]["modified"] = modified_arr;
+ }
+
+ notifyAll("object.update", update);
+}
+
+void LLScriptEditorWSServer::cleanupPrimListeners(const LLUUID& object_id)
+{
+ // Clear any pending publish listeners
+ auto pending_it = mPendingPublishes.find(object_id);
+ if (pending_it != mPendingPublishes.end())
+ {
+ pending_it->second.mListeners.clear(); // unique_ptrs call removeVOInventoryListener()
+ mPendingPublishes.erase(pending_it);
+ }
+
+ // Clear published object listeners (Phase 4)
+ auto pub_it = mPublishedObjects.find(object_id);
+ if (pub_it != mPublishedObjects.end())
+ {
+ pub_it->second.mListeners.clear();
+ }
+}
+
+void LLScriptEditorWSServer::unpublishObject(const LLUUID& object_id, const std::string& reason)
+{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
+ auto it = mPublishedObjects.find(object_id);
+ if (it == mPublishedObjects.end())
+ {
+ // May still have a pending publish in progress -- cancel it
+ cleanupPrimListeners(object_id);
+ return;
+ }
+
+ cleanupPrimListeners(object_id);
+ mPublishedObjects.erase(it);
+
+ // Cancel any pending linkset flush so it cannot fire after removal
+ cancelLinksetFlushTimer(object_id);
+ mNewChildPrims.erase(object_id);
+
+ LLSD message;
+ message["object_id"] = object_id;
+ if (!reason.empty())
+ {
+ message["reason"] = reason;
+ }
+ notifyAll("object.unpublish", message);
+
+ LL_DEBUGS("ScriptEditorWS") << "Unpublished object " << object_id
+ << " reason: " << reason << LL_ENDL;
+}
+
+
//========================================================================
-U32 LLScriptEditorWSConnection::sNextConnectionID = 1;
+std::atomic<U32> LLScriptEditorWSConnection::sNextConnectionID{1};
std::shared_ptr<LLScriptEditorWSServer> LLScriptEditorWSConnection::getServer() const
{
@@ -773,6 +2591,7 @@ std::shared_ptr<LLScriptEditorWSServer> LLScriptEditorWSConnection::getServer()
void LLScriptEditorWSConnection::onOpen()
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
// Call parent class to set up JSON-RPC infrastructure
LLJSONRPCConnection::onOpen();
@@ -807,7 +2626,7 @@ void LLScriptEditorWSConnection::onOpen()
features["syntax_cache"] = true;
handshake["features"] = features;
- wptr_t that = shared_from_this();
+ wptr_t that = weak_from_this();
// Send session.handshake method call and the response
call("session.handshake", handshake, [that](const LLSD& result, const LLSD& error) {
@@ -831,6 +2650,7 @@ void LLScriptEditorWSConnection::onOpen()
void LLScriptEditorWSConnection::onClose()
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
// Call parent class to clean up JSON-RPC infrastructure
LLJSONRPCConnection::onClose();
mOwningServer.reset();
@@ -845,11 +2665,11 @@ void LLScriptEditorWSConnection::onClose()
mFeatures.clear();
}
-void LLScriptEditorWSConnection::sendDisconnect(S32 reason, const std::string& message)
+void LLScriptEditorWSConnection::sendDisconnect(DisconnectReason reason, const std::string& message)
{
LL_INFOS("ScriptEditorWS") << "Sending disconnect to client: " << message << LL_ENDL;
LLSD params;
- params["reason"] = reason;
+ params["reason"] = static_cast<S32>(reason);
params["message"] = message;
notify("session.disconnect", params);
closeConnection(1000, message);
@@ -857,44 +2677,48 @@ void LLScriptEditorWSConnection::sendDisconnect(S32 reason, const std::string& m
void LLScriptEditorWSConnection::handleHandshakeResponse(const LLSD& result)
{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
LL_INFOS("ScriptEditorWS") << "Processing handshake response from client" << LL_ENDL;
- // Extract and validate client information
- mClientName = result["client_name"].asString();
- mClientVersion = result["client_version"].asString();
+ mClientName = result["client_name"].asString();
+ mClientVersion = result["client_version"].asString();
mProtocolVersion = result["protocol_version"].asString();
- if (mChallenge.notNull())
+ // Validate challenge response (if a challenge was issued).
+ const bool challenge_issued = mChallenge.notNull();
+ bool valid_response = true;
+ if (challenge_issued)
{
- // Validate challenge response
- bool valid_response = (result.has("challenge_response") &&
- (result["challenge_response"].asUUID() == mChallenge));
+ valid_response = result.has("challenge_response") &&
+ (result["challenge_response"].asUUID() == mChallenge);
+ mChallenge.setNull();
+ }
+ // Always clean up the temporary challenge file if one was created,
+ // regardless of validation outcome.
+ if (!mChallengeFile.empty())
+ {
LLFile::remove(mChallengeFile);
mChallengeFile.clear();
- mChallenge.setNull();
- if (!valid_response)
- {
- LL_WARNS("ScriptEditorWS") << "Invalid or missing challenge response from client" << LL_ENDL;
- sendDisconnect(REASON_PROTOCOL_ERROR, "Invalid challenge response");
- return;
- }
}
- LLUUID challenge_response = result["challenge_response"].asUUID();
- // Validate protocol compatibility
+ if (challenge_issued && !valid_response)
+ {
+ LL_WARNS("ScriptEditorWS") << "Invalid or missing challenge response from client" << LL_ENDL;
+ sendDisconnect(DisconnectReason::PROTOCOL_ERROR, "Invalid challenge response");
+ return;
+ }
+
if (mProtocolVersion != "1.0")
{
LL_WARNS("ScriptEditorWS") << "Protocol version mismatch. Expected: 1.0, Got: "
<< mProtocolVersion << LL_ENDL;
}
- // Store script information if provided
- mScriptName = result["script_name"].asString();
+ mScriptName = result["script_name"].asString();
mScriptLanguage = result["script_language"].asString();
- // Store supported languages
- for (const auto& lang : llsd::inArray( result["languages"]))
+ for (const auto& lang : llsd::inArray(result["languages"]))
{
if (lang.isString())
{
@@ -910,14 +2734,6 @@ void LLScriptEditorWSConnection::handleHandshakeResponse(const LLSD& result)
}
}
- if (mChallenge.notNull())
- {
- // Remove temporary challenge file
- LLFile::remove(mChallengeFile);
- mChallenge.setNull();
- mChallengeFile.clear();
- }
-
notify("session.ok");
LL_INFOS("ScriptEditorWS") << "Handshake completed successfully." << LL_ENDL;
@@ -927,12 +2743,12 @@ std::string LLScriptEditorWSConnection::generateChallenge()
{
mChallenge.generate();
- mChallengeFile = std::string(LLFile::tmpdir()) + "sl_script_challenge.tmp";
+ mChallengeFile = std::string(LLFile::tmpdir()) + "sl_script_challenge_" + mChallenge.asString() + ".tmp";
llofstream file(mChallengeFile.c_str());
if (!file.is_open())
{
- LL_WARNS() << "Unable to open challenge file: " << mChallengeFile << LL_ENDL;
+ LL_WARNS("ScriptEditorWS") << "Unable to open challenge file: " << mChallengeFile << LL_ENDL;
mChallenge.setNull();
mChallengeFile.clear();
return std::string();
diff --git a/indra/newview/llscripteditorws.h b/indra/newview/llscripteditorws.h
index 765ebe83f2..33b19f8a97 100644
--- a/indra/newview/llscripteditorws.h
+++ b/indra/newview/llscripteditorws.h
@@ -31,11 +31,13 @@
#include "lluuid.h"
#include "llhandle.h"
#include "lltimer.h"
+#include "lleventtimer.h"
#include <memory>
#include <string>
#include <map>
#include <set>
+#include <atomic>
// Forward declarations
class LLLiveLSLEditor;
@@ -43,6 +45,9 @@ class LLScriptEdContainer;
class LLScriptEditorWSServer;
class LLChat;
class LLPanel;
+class LLPublishedPrimListener;
+class LLViewerObject;
+class LLInventoryItem;
class LLScriptEditorWSConnection : public LLJSONRPCConnection, public std::enable_shared_from_this<LLScriptEditorWSConnection>
{
@@ -50,19 +55,27 @@ public:
using ptr_t = std::shared_ptr<LLScriptEditorWSConnection>;
using wptr_t = std::weak_ptr<LLScriptEditorWSConnection>;
- enum DisconnectReason
+ enum class DisconnectReason : S32
{
- REASON_NORMAL = 0,
- REASON_EDITOR_CLOSED = 1,
- REASON_PROTOCOL_ERROR = 2,
- REASON_TIMEOUT = 3,
- REASON_INTERNAL_ERROR = 4
+ NORMAL = 0,
+ EDITOR_CLOSED = 1,
+ PROTOCOL_ERROR = 2,
+ TIMEOUT = 3,
+ INTERNAL_ERROR = 4
};
LLScriptEditorWSConnection(const LLWebsocketMgr::WSServer::ptr_t server, const LLWebsocketMgr::connection_h& handle) :
LLJSONRPCConnection(server, handle)
{
- mConnectionID = sNextConnectionID++;
+ // Reserve id 0 as the "unassigned" sentinel used by EditorSubscription;
+ // on wrap, skip past it.
+ U32 id;
+ do
+ {
+ id = sNextConnectionID.fetch_add(1, std::memory_order_relaxed);
+ }
+ while (id == 0);
+ mConnectionID = id;
}
~LLScriptEditorWSConnection() override = default;
@@ -73,7 +86,7 @@ public:
void onOpen() override;
void onClose() override;
- void sendDisconnect(S32 reason = 0, const std::string& message = "Goodbye");
+ void sendDisconnect(DisconnectReason reason = DisconnectReason::NORMAL, const std::string& message = "Goodbye");
private:
using string_set_t = std::set<std::string>;
@@ -100,7 +113,7 @@ private:
LLUUID mChallenge;
std::string mChallengeFile; ///< Temporary file used for challenge-response verification
- static U32 sNextConnectionID;
+ static std::atomic<U32> sNextConnectionID;
};
/**
@@ -142,16 +155,17 @@ private:
class LLScriptEditorWSServer : public LLJSONRPCServer
{
public:
- enum SubscriptionError_t
+ static constexpr U32 ALL_CONNECTIONS = 0xFFFFFFFF;
+ enum class SubscriptionError
{
- SUBSCRIPTION_SUCCESS = 0,
- SUBSCRIPTION_INVALID_EDITOR,
- SUBSCRIPTION_INVALID_SUBSCRIPTION,
- SUBSCRIPTION_ALREADY_SUBSCRIBED,
- SUBSCRIPTION_INTERNAL_ERROR
+ SUCCESS = 0,
+ INVALID_EDITOR,
+ INVALID_SUBSCRIPTION,
+ ALREADY_SUBSCRIBED,
+ INTERNAL_ERROR
};
- static constexpr char const* DEFAULT_SERVER_NAME = "script_editor_server";
+ static constexpr const char* DEFAULT_SERVER_NAME = "script_editor_server";
static constexpr U16 DEFAULT_SERVER_PORT = 9020;
using ptr_t = std::shared_ptr<LLScriptEditorWSServer>;
@@ -159,9 +173,14 @@ public:
LLScriptEditorWSServer(const std::string& name, U16 port, bool local_only = true);
- virtual ~LLScriptEditorWSServer() = default;
+ ~LLScriptEditorWSServer() override = default;
static LLScriptEditorWSServer::ptr_t getServer();
+ static LLScriptEditorWSServer::ptr_t ensureServerRunning();
+ static std::string buildVSCodeURI(const LLUUID& object_id = LLUUID::null,
+ const LLUUID& script_id = LLUUID::null);
+ static bool launchVSCode(const LLUUID& object_id = LLUUID::null,
+ const LLUUID& script_id = LLUUID::null);
void onStarted() override;
void onStopped() override;
@@ -182,6 +201,19 @@ public:
std::set<std::string> getActiveScripts() const;
+ // --- Object Content Publishing ---
+ bool publishObject(const LLUUID& object_id);
+ void unpublishObject(const LLUUID& object_id, const std::string& reason = "");
+ bool isObjectPublished(const LLUUID& object_id) const;
+ void onPrimInventoryReady(const LLUUID& object_id, const LLUUID& prim_id);
+ void onPrimInventoryChanged(const LLUUID& object_id, const LLUUID& prim_id);
+ void onObjectPropertyChanged(const LLUUID& prim_id, const std::string& name, const std::string& desc);
+ void onLinksetChildAdded(const LLUUID& root_id, LLViewerObject* child);
+ void onLinksetChildRemoved(const LLUUID& root_id, const LLUUID& child_id);
+
+ static bool isEnabled() { return sEnableScriptEditorWS; }
+ static bool isTightIntegration() { return sTightIntegration; }
+
protected:
LLWebsocketMgr::WSConnection::ptr_t connectionFactory(LLWebsocketMgr::WSServer::ptr_t server,
LLWebsocketMgr::connection_h handle) override;
@@ -197,6 +229,61 @@ protected:
LLSD handleScriptSubscribe(U32 connection_id, const LLSD& params);
LLSD handleScriptUnsubscribe(U32 connection_id, const LLSD& params);
LLSD handleFileWatcherFileListRequest() const;
+ LLSD handleObjectRequest(U32 connection_id, const LLSD& params);
+ LLSD handleObjectContentGet(const std::string& method, const LLSD& id, const LLSD& params);
+ LLSD handleObjectContentSave(const std::string& method, const LLSD& id, const LLSD& params);
+ LLSD saveScript(LLViewerObject* prim, LLInventoryItem* item, const std::string& content, const LLSD& params);
+ LLSD saveNotecard(LLViewerObject* prim, LLInventoryItem* item, const std::string& content);
+ LLSD handleObjectItemDelete(U32 connection_id, const LLSD& params);
+ LLSD handleObjectItemCreate(const std::string& method, const LLSD& id, const LLSD& params);
+ LLSD handleObjectUnpublish(U32 connection_id, const LLSD& params);
+ LLSD handleObjectList() const;
+ LLSD handleObjectScriptSetRunning(U32 connection_id, const LLSD& params);
+ LLSD handleObjectScriptReset(U32 connection_id, const LLSD& params);
+ LLSD handleObjectModify(U32 connection_id, const LLSD& params);
+ LLSD handleObjectItemModify(U32 connection_id, const LLSD& params);
+ LLSD buildPublishedObjectLLSD(LLViewerObject* root) const;
+
+ struct ValidatedItem
+ {
+ LLViewerObject* prim{ nullptr };
+ LLViewerObject* root{ nullptr };
+ LLInventoryItem* item{ nullptr };
+ LLAssetType::EType type{ LLAssetType::AT_NONE };
+ };
+ ValidatedItem validatePublishedItem(const LLSD& params, U32 permMask) const;
+
+ // --- Object Content Publishing (helpers) ---
+ static std::string getPrimName(LLViewerObject* obj);
+ LLSD buildPrimInventoryLLSD(LLViewerObject* object) const;
+ void notifyConnection(U32 connection_id, const std::string& method, const LLSD& params) const;
+ void notifyAll(const std::string& method, const LLSD& params) const;
+ void cleanupPrimListeners(const LLUUID& object_id);
+ void buildAndSendPublish(const LLUUID& object_id);
+ void scheduleLinksetFlush(const LLUUID& root_id, F32 delay);
+ void cancelLinksetFlushTimer(const LLUUID& root_id);
+ void flushLinksetUpdate(const LLUUID& root_id);
+ static LLSD errorResponse(const std::string& message);
+
+ /// Wraps `fn` in a MethodHandler with a weak-ptr guard on this server,
+ /// so the handler safely no-ops after server shutdown. `fn` is called
+ /// with (LLScriptEditorWSServer&, method, id, params) and returns LLSD.
+ template <typename Fn>
+ LLJSONRPCConnection::MethodHandler bindHandler(Fn fn)
+ {
+ std::weak_ptr<LLWebsocketMgr::WSServer> weak_base = weak_from_this();
+ return [weak_base, fn = std::move(fn)]
+ (const std::string& method, const LLSD& id, const LLSD& params) -> LLSD
+ {
+ auto base = weak_base.lock();
+ if (!base)
+ {
+ return LLSD();
+ }
+ auto server = std::static_pointer_cast<LLScriptEditorWSServer>(base);
+ return fn(*server, method, id, params);
+ };
+ }
private:
struct EditorSubscription
@@ -216,12 +303,49 @@ private:
};
using subscriptions_t = std::unordered_map<std::string, EditorSubscription>;
- SubscriptionError_t updateScriptSubscription(const std::string &script_id, U32 connection_id);
+ struct PublishedPrimInfo
+ {
+ LLUUID mPrimID;
+ std::string mPrimName;
+ S32 mLinkNumber; // 1=root, >=2=child
+ S16 mInventorySerial; // last-seen serial for change detection (Phase 4)
+ };
+
+ struct PublishedObjectInfo
+ {
+ LLUUID mObjectID; // root prim UUID
+ LLUUID mOwnerID;
+ std::string mObjectName;
+ std::string mObjectDescription;
+ std::string mRegionName;
+ std::vector<PublishedPrimInfo> mPrims; // root + all children
+ std::vector<std::unique_ptr<LLPublishedPrimListener>> mListeners;
+ };
+
+ struct PendingPublish
+ {
+ LLUUID mObjectID;
+ std::set<LLUUID> mPendingPrims; // prims whose inventory we're still waiting for
+ std::vector<std::unique_ptr<LLPublishedPrimListener>> mListeners; // listeners for pending prims
+ };
+
+ SubscriptionError updateScriptSubscription(const std::string &script_id, U32 connection_id);
void unsubscribeConnection(U32 connection_id);
subscriptions_t mSubscriptions;
+ // Per-connection subscription count. Invariant: for c != 0,
+ // mConnectionSubscriptionCounts[c] == count of entries in mSubscriptions
+ // whose mConnectionID == c. Maintained transactionally at every site that
+ // mutates SubscriptionInfo::mConnectionID.
+ std::unordered_map<U32, S32> mConnectionSubscriptionCounts;
std::map<U32, LLScriptEditorWSConnection::wptr_t> mActiveConnections;
+ std::map<LLUUID, PublishedObjectInfo> mPublishedObjects; // keyed by root object_id
+ std::map<LLUUID, PendingPublish> mPendingPublishes; // keyed by root object_id
+ std::map<LLUUID, std::string> mPendingItemCreates; // prim_id -> pump name awaiting inventory update
+ std::map<LLUUID, std::set<LLUUID>> mNewChildPrims; // root_id → children awaiting first inventory response
+ std::map<LLUUID, std::weak_ptr<LLEventTimer>> mLinksetFlushTimers; // root_id → pending coalesce timer
+
boost::signals2::connection mLanguageChangeSignal;
LLUUID mLastSyntaxId;
@@ -229,4 +353,7 @@ private:
LLTimer mCleanupTimer;
static constexpr F32 CLEANUP_INTERVAL = 60.0f; // seconds
static constexpr F32 CONNECTION_TIMEOUT = 300.0f; // 5 minutes
+
+ static LLCachedControl<bool> sEnableScriptEditorWS;
+ static LLCachedControl<bool> sTightIntegration;
};
diff --git a/indra/newview/llselectmgr.cpp b/indra/newview/llselectmgr.cpp
index 9eacf53c49..d13b7296f4 100644
--- a/indra/newview/llselectmgr.cpp
+++ b/indra/newview/llselectmgr.cpp
@@ -97,6 +97,7 @@
#include "llpanelface.h"
#include "llglheaders.h"
#include "llinventoryobserver.h"
+#include "llscripteditorws.h"
LLViewerObject* getSelectedParentObject(LLViewerObject *object) ;
//
@@ -6108,6 +6109,11 @@ void LLSelectMgr::processObjectProperties(LLMessageSystem* msg, void** user_data
node->mInventorySerial = inv_serial;
node->mSitName.assign(sit_name);
node->mTouchName.assign(touch_name);
+
+ if (auto ws_server = LLScriptEditorWSServer::getServer())
+ {
+ ws_server->onObjectPropertyChanged(id, name, desc);
+ }
}
}
@@ -6204,6 +6210,11 @@ void LLSelectMgr::processObjectPropertiesFamily(LLMessageSystem* msg, void** use
}
dialog_refresh_all();
+
+ if (auto ws_server = LLScriptEditorWSServer::getServer())
+ {
+ ws_server->onObjectPropertyChanged(id, name, desc);
+ }
}
diff --git a/indra/newview/llviewerinventory.cpp b/indra/newview/llviewerinventory.cpp
index 53d87e2869..be43c9c785 100644
--- a/indra/newview/llviewerinventory.cpp
+++ b/indra/newview/llviewerinventory.cpp
@@ -506,6 +506,16 @@ bool LLViewerInventoryItem::unpackMessage(const LLSD& item)
LLLocalizedInventoryItemsDictionary::getInstance()->localizeInventoryObjectName(mName);
+ // Parse script runtime state from task inventory cap
+ if (item.has("running"))
+ {
+ mIsRunning = item["running"].asBoolean();
+ }
+ if (item.has("faulted"))
+ {
+ mIsFaulted = item["faulted"].asBoolean();
+ }
+
mIsComplete = true;
return rv;
}
diff --git a/indra/newview/llviewerinventory.h b/indra/newview/llviewerinventory.h
index 23f85663e8..ff1349d908 100644
--- a/indra/newview/llviewerinventory.h
+++ b/indra/newview/llviewerinventory.h
@@ -151,6 +151,10 @@ public:
};
LLTransactionID getTransactionID() const { return mTransactionID; }
+ // Script runtime state (from task inventory cap)
+ bool getIsRunning() const { return mIsRunning; }
+ bool getIsFaulted() const { return mIsFaulted; }
+
bool getIsBrokenLink() const; // true if the baseitem this points to doesn't exist in memory.
LLViewerInventoryItem *getLinkedItem() const;
LLViewerInventoryCategory *getLinkedCategory() const;
@@ -168,6 +172,10 @@ public:
public:
bool mIsComplete;
LLTransactionID mTransactionID;
+
+ // Script runtime state (only valid for task inventory scripts)
+ bool mIsRunning = false;
+ bool mIsFaulted = false;
};
diff --git a/indra/newview/llviewermenu.cpp b/indra/newview/llviewermenu.cpp
index 1095f56863..dc509479c4 100644
--- a/indra/newview/llviewermenu.cpp
+++ b/indra/newview/llviewermenu.cpp
@@ -75,6 +75,7 @@
#include "llfloatersearch.h"
#include "llfloaterscriptdebug.h"
#include "llfloatersnapshot.h"
+#include "llscripteditorws.h"
#include "llfloatertools.h"
#include "llfloaterworldmap.h"
#include "llfloaterbuildoptions.h"
@@ -5795,6 +5796,47 @@ class LLToolsCheckSelectionLODMode : public view_listener_t
};
+class LLToolsCheckScriptEditorServer : public view_listener_t
+{
+ bool handleEvent(const LLSD& userdata)
+ {
+ LLScriptEditorWSServer::ptr_t server = LLScriptEditorWSServer::getServer();
+ return server && server->isRunning();
+ }
+};
+
+class LLToolsEnableScriptEditorServer : public view_listener_t
+{
+ bool handleEvent(const LLSD& userdata)
+ {
+ return LLScriptEditorWSServer::isEnabled();
+ }
+};
+
+class LLToolsToggleScriptEditorServer : public view_listener_t
+{
+ bool handleEvent(const LLSD& userdata)
+ {
+ LLScriptEditorWSServer::ptr_t server = LLScriptEditorWSServer::getServer();
+ if (server && server->isRunning())
+ {
+ LLWebsocketMgr::instance().stopServer(LLScriptEditorWSServer::DEFAULT_SERVER_NAME);
+ }
+ else
+ {
+ if (LLScriptEditorWSServer::isTightIntegration())
+ {
+ LLScriptEditorWSServer::launchVSCode();
+ }
+ else
+ {
+ LLScriptEditorWSServer::ensureServerRunning();
+ }
+ }
+ return true;
+ }
+};
+
// Round the position of all root objects to the grid
class LLToolsSnapObjectXY : public view_listener_t
{
@@ -9963,6 +10005,10 @@ void initialize_menus()
view_listener_t::addMenu(new LLToolsEnablePathfindingRebakeRegion(), "Tools.EnablePathfindingRebakeRegion");
view_listener_t::addMenu(new LLToolsCheckSelectionLODMode(), "Tools.ToolsCheckSelectionLODMode");
+ view_listener_t::addMenu(new LLToolsCheckScriptEditorServer(), "Tools.CheckScriptEditorServer");
+ view_listener_t::addMenu(new LLToolsEnableScriptEditorServer(), "Tools.EnableScriptEditorServer");
+ view_listener_t::addMenu(new LLToolsToggleScriptEditorServer(), "Tools.ToggleScriptEditorServer");
+
// Help menu
// most items use the ShowFloater method
view_listener_t::addMenu(new LLToggleHowTo(), "Help.ToggleHowTo");
diff --git a/indra/newview/llviewerobject.cpp b/indra/newview/llviewerobject.cpp
index 95e9d58b57..f1b0819c18 100644
--- a/indra/newview/llviewerobject.cpp
+++ b/indra/newview/llviewerobject.cpp
@@ -27,6 +27,7 @@
#include "llviewerprecompiledheaders.h"
#include "llviewerobject.h"
+#include "llscripteditorws.h"
#include "llaudioengine.h"
#include "indra_constants.h"
@@ -419,6 +420,13 @@ void LLViewerObject::markDead()
LL_PROFILE_ZONE_SCOPED;
//LL_INFOS() << "Marking self " << mLocalID << " as dead." << LL_ENDL;
+ // If this is a published root prim, notify the WS extension before teardown.
+ auto ws_server = LLScriptEditorWSServer::getServer();
+ if (ws_server && ws_server->isObjectPublished(mID))
+ {
+ ws_server->unpublishObject(mID, "deleted");
+ }
+
// Root object of this hierarchy unlinks itself.
if (getParent())
{
@@ -743,6 +751,18 @@ void LLViewerObject::setNameValueList(const std::string& name_value_list)
}
start = end+1;
}
+
+ if (auto ws_server = LLScriptEditorWSServer::getServer())
+ {
+ LLNameValue* nv_name = getNVPair("Name");
+ LLNameValue* nv_desc = getNVPair("Desc");
+ if (nv_name || nv_desc)
+ {
+ std::string obj_name = nv_name ? nv_name->getString() : "";
+ std::string obj_desc = nv_desc ? nv_desc->getString() : "";
+ ws_server->onObjectPropertyChanged(mID, obj_name, obj_desc);
+ }
+ }
}
bool LLViewerObject::isAnySelected() const
@@ -935,6 +955,24 @@ void LLViewerObject::addChild(LLViewerObject *childp)
{
mSeatCount++;
}
+ else
+ {
+ if (auto ws_server = LLScriptEditorWSServer::getServer())
+ {
+ // If the child was itself a published root, unpublish it — it is now a child prim
+ if (ws_server->isObjectPublished(childp->getID()))
+ {
+ ws_server->unpublishObject(childp->getID(), "linked");
+ }
+
+ // If our root is a published object, notify of the structural change
+ LLUUID root_id = getRootEdit()->getID();
+ if (ws_server->isObjectPublished(root_id))
+ {
+ ws_server->onLinksetChildAdded(root_id, childp);
+ }
+ }
+ }
}
}
@@ -978,6 +1016,18 @@ void LLViewerObject::removeChild(LLViewerObject *childp)
bool add_to_end = true;
LLSelectMgr::getInstance()->selectObjectAndFamily(childp, add_to_end);
}
+
+ // Notify WS server of linkset structure change
+ if (!isDead() && !childp->isDead() && !childp->isAvatar())
+ {
+ if (auto ws_server = LLScriptEditorWSServer::getServer())
+ {
+ if (ws_server->isObjectPublished(getID()))
+ {
+ ws_server->onLinksetChildRemoved(getID(), childp->getID());
+ }
+ }
+ }
}
void LLViewerObject::addThisAndAllChildren(std::vector<LLViewerObject*>& objects)
@@ -2817,6 +2867,89 @@ void LLViewerObject::saveScript(const LLViewerInventoryItem* item,
doUpdateInventory(task_item, TASK_INVENTORY_ITEM_KEY, is_new);
}
+void LLViewerObject::createInventoryItem(
+ LLAssetType::EType asset_type,
+ LLInventoryType::EType inventory_type,
+ U8 sub_type,
+ const std::string& name,
+ const std::string& description,
+ const LLPermissions& permissions,
+ const LLSD& params,
+ std::function<void(bool, const LLSD&)> callback)
+{
+ if (!mRegionp)
+ {
+ if (callback) callback(false, LLSD().with("message", "No region"));
+ return;
+ }
+
+ std::string cap_url = mRegionp->getCapability("CreateTaskInventoryItem");
+ if (cap_url.empty())
+ {
+ if (callback) callback(false, LLSD().with("message", "Capability not available"));
+ return;
+ }
+
+ LLSD body;
+ body["object_id"] = mID;
+ body["inventory_type"] = (S32)inventory_type;
+ body["asset_type"] = (S32)asset_type;
+ body["sub_type"] = (S32)sub_type;
+ body["name"] = name;
+ body["description"] = description;
+
+ LLSD perm_llsd;
+ perm_llsd["base"] = (S32)permissions.getMaskBase();
+ perm_llsd["owner"] = (S32)permissions.getMaskOwner();
+ perm_llsd["everyone"] = (S32)permissions.getMaskEveryone();
+ perm_llsd["group"] = (S32)permissions.getMaskGroup();
+ perm_llsd["next_owner"] = (S32)permissions.getMaskNextOwner();
+ body["permissions"] = perm_llsd;
+
+ if (params.isDefined())
+ {
+ body["params"] = params;
+ }
+
+ LLCoros::instance().launch("LLViewerObject::createInventoryItemCoro",
+ boost::bind(&LLViewerObject::createInventoryItemCoro, cap_url, body, callback));
+}
+
+// static
+void LLViewerObject::createInventoryItemCoro(
+ const std::string cap_url, const LLSD body,
+ std::function<void(bool, const LLSD&)> callback)
+{
+ LLCore::HttpRequest::policy_t httpPolicy(LLCore::HttpRequest::DEFAULT_POLICY_ID);
+ LLCoreHttpUtil::HttpCoroutineAdapter::ptr_t httpAdapter =
+ std::make_shared<LLCoreHttpUtil::HttpCoroutineAdapter>("CreateTaskInventoryItem", httpPolicy);
+ LLCore::HttpRequest::ptr_t httpRequest = std::make_shared<LLCore::HttpRequest>();
+
+ LLSD result = httpAdapter->postAndSuspend(httpRequest, cap_url, body);
+
+ LLSD httpResults = result[LLCoreHttpUtil::HttpCoroutineAdapter::HTTP_RESULTS];
+ LLCore::HttpStatus status = LLCoreHttpUtil::HttpCoroutineAdapter::getStatusFromLLSD(httpResults);
+
+ bool success = static_cast<bool>(status) && result["success"].asBoolean();
+
+ if (success)
+ {
+ LLUUID object_id = body["object_id"].asUUID();
+ LLViewerObject* obj = gObjectList.findObject(object_id);
+ if (obj)
+ {
+ ++obj->mExpectedInventorySerialNum;
+ obj->dirtyInventory();
+ obj->requestInventory();
+ }
+ }
+
+ if (callback)
+ {
+ callback(success, result);
+ }
+}
+
void LLViewerObject::moveInventory(const LLUUID& folder_id,
const LLUUID& item_id)
{
diff --git a/indra/newview/llviewerobject.h b/indra/newview/llviewerobject.h
index b6ce4c4505..6777753754 100644
--- a/indra/newview/llviewerobject.h
+++ b/indra/newview/llviewerobject.h
@@ -553,6 +553,16 @@ public:
// script subtype in the item will be used to select the correct template.
void saveScript(const LLViewerInventoryItem* item, bool active, bool is_new, const LLUUID& template_id);
+ void createInventoryItem(
+ LLAssetType::EType asset_type,
+ LLInventoryType::EType inventory_type,
+ U8 sub_type,
+ const std::string& name,
+ const std::string& description,
+ const LLPermissions& permissions,
+ const LLSD& params,
+ std::function<void(bool, const LLSD&)> callback);
+
// move an inventory item out of the task and into agent
// inventory. This operation is based on messaging. No permissions
// checks are made on the viewer - the server will double check.
@@ -733,6 +743,8 @@ private:
void fetchInventoryDelayed(const F64 &time_seconds);
static void fetchInventoryDelayedCoro(const LLUUID task_inv, const F64 time_seconds);
static void fetchInventoryFromCapCoro(const LLUUID task_inv);
+ static void createInventoryItemCoro(const std::string cap_url, const LLSD body,
+ std::function<void(bool, const LLSD&)> callback);
public:
//
diff --git a/indra/newview/llviewerregion.cpp b/indra/newview/llviewerregion.cpp
index ac93cb75ea..0bb3edfc7e 100644
--- a/indra/newview/llviewerregion.cpp
+++ b/indra/newview/llviewerregion.cpp
@@ -3243,6 +3243,7 @@ void LLViewerRegionImpl::buildCapabilityNames(LLSD& capabilityNames)
capabilityNames.append("FetchInventory2");
capabilityNames.append("FetchInventoryDescendents2");
capabilityNames.append("IncrementCOFVersion");
+ capabilityNames.append("CreateTaskInventoryItem");
capabilityNames.append("RequestTaskInventory");
AISAPI::getCapNames(capabilityNames);
diff --git a/indra/newview/skins/default/textures/icons/Inv_Script_Luau.png b/indra/newview/skins/default/textures/icons/Inv_Script_Luau.png
new file mode 100644
index 0000000000..5666193fab
--- /dev/null
+++ b/indra/newview/skins/default/textures/icons/Inv_Script_Luau.png
Binary files differ
diff --git a/indra/newview/skins/default/textures/textures.xml b/indra/newview/skins/default/textures/textures.xml
index 1f4b12fc91..b7f6d23bcc 100644
--- a/indra/newview/skins/default/textures/textures.xml
+++ b/indra/newview/skins/default/textures/textures.xml
@@ -332,6 +332,7 @@ with the same filename but different name
<texture name="Inv_Object_Multi" file_name="icons/Inv_Object_Multi.png" preload="false" />
<texture name="Inv_Pants" file_name="icons/Inv_Pants.png" preload="false" />
<texture name="Inv_Script" file_name="icons/Inv_Script.png" preload="false" />
+ <texture name="Inv_Script_Luau" file_name="icons/Inv_Script_Luau.png" preload="false" />
<texture name="Inv_Shirt" file_name="icons/Inv_Shirt.png" preload="false" />
<texture name="Inv_Shoe" file_name="icons/Inv_Shoe.png" preload="false" />
<texture name="Inv_Skin" file_name="icons/Inv_Skin.png" preload="false" />
diff --git a/indra/newview/skins/default/xui/en/floater_scripting_settings.xml b/indra/newview/skins/default/xui/en/floater_scripting_settings.xml
index 07f073edb2..093c3f1d6b 100644
--- a/indra/newview/skins/default/xui/en/floater_scripting_settings.xml
+++ b/indra/newview/skins/default/xui/en/floater_scripting_settings.xml
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<floater
legacy_header_height="18"
- height="265"
+ height="295"
layout="topleft"
name="floater_scripting_settings"
help_topic="scripting_settings"
@@ -82,6 +82,17 @@
</button>
<check_box
+ control_name="ExternalEditorTightIntegration"
+ follows="top|left"
+ height="20"
+ label="VS Code Tight Integration"
+ layout="topleft"
+ left="30"
+ name="tight_integration"
+ top_pad="20"
+ width="256" />
+
+ <check_box
control_name="ExternalWebsocketSyncEnable"
follows="top|left"
height="20"
@@ -89,7 +100,7 @@
layout="topleft"
left="30"
name="websocket_sync_enable"
- top_pad="20"
+ top_pad="10"
width="200" />
<text
diff --git a/indra/newview/skins/default/xui/en/floater_tools.xml b/indra/newview/skins/default/xui/en/floater_tools.xml
index d94a6c71b5..39762d324f 100644
--- a/indra/newview/skins/default/xui/en/floater_tools.xml
+++ b/indra/newview/skins/default/xui/en/floater_tools.xml
@@ -3058,35 +3058,56 @@ even though the user gets a free copy.
name="Contents"
top_delta="0"
width="295">
- <button
+ <flyout_button
follows="left|top"
height="23"
label="New Script"
- label_selected="New Script"
layout="topleft"
- left="8"
+ left="7"
name="button new script"
top="10"
- width="134"
- font="DejaVu"
- font.size="LSmall"
- pad_bottom="1" />
+ width="137">
+ <flyout_button.item
+ label="New LSL Script"
+ name="new_lsl_script"
+ value="lsl" />
+ <flyout_button.item
+ label="New Lua Script"
+ name="new_lua_script"
+ value="lua" />
+ </flyout_button>
+ <button
+ follows="left|top"
+ height="23"
+ label="New Notecard"
+ layout="topleft"
+ left_pad="7"
+ name="button new notecard"
+ width="137" />
<button
follows="left|top"
height="23"
label="Permissions"
layout="topleft"
- left_pad="8"
+ left="7"
+ top="37"
name="button permissions"
- width="134"
- font="DejaVu"
- font.size="LSmall"
- pad_bottom="1" />
+ width="137" />
+ <button
+ follows="left|top"
+ height="23"
+ is_toggle="true"
+ label="Explore in IDE"
+ label_selected="Stop Exploring"
+ layout="topleft"
+ left_pad="7"
+ name="button publish"
+ width="137" />
<filter_editor
follows="left|top|right"
label="Enter filter text"
layout="topleft"
- top="40"
+ top="67"
left="10"
text_pad_left="10"
max_length_chars="300"
@@ -3099,11 +3120,11 @@ even though the user gets a free copy.
border_visible="true"
bevel_style="in"
follows="left|top|right"
- height="367"
+ height="340"
layout="topleft"
left="10"
name="contents_inventory"
- top="70"
+ top="97"
width="275" />
</panel>
</tab_container>
diff --git a/indra/newview/skins/default/xui/en/menu_viewer.xml b/indra/newview/skins/default/xui/en/menu_viewer.xml
index ffe4bcebd5..34b30e424d 100644
--- a/indra/newview/skins/default/xui/en/menu_viewer.xml
+++ b/indra/newview/skins/default/xui/en/menu_viewer.xml
@@ -1384,14 +1384,25 @@ function="World.EnvPreset"
function="EditableSelected" />
</menu_item_call>
<menu_item_call
- label="Set Scripts to Not Running"
- name="Set Scripts to Not Running">
+ label="Set Scripts to Not Running"
+ name="Set Scripts to Not Running">
<menu_item_call.on_click
- function="Tools.SelectedScriptAction"
- parameter="stop" />
+ function="Tools.SelectedScriptAction"
+ parameter="stop" />
<menu_item_call.on_enable
- function="EditableSelected" />
+ function="EditableSelected" />
</menu_item_call>
+ <menu_item_separator/>
+ <menu_item_check
+ label="Script Editor Server"
+ name="Script Editor Server">
+ <menu_item_check.on_check
+ function="Tools.CheckScriptEditorServer" />
+ <menu_item_check.on_click
+ function="Tools.ToggleScriptEditorServer" />
+ <menu_item_check.on_enable
+ function="Tools.EnableScriptEditorServer" />
+ </menu_item_check>
</menu>
<menu
diff --git a/indra/newview/skins/default/xui/en/strings.xml b/indra/newview/skins/default/xui/en/strings.xml
index b8e71928dc..0dbcfe9bfe 100644
--- a/indra/newview/skins/default/xui/en/strings.xml
+++ b/indra/newview/skins/default/xui/en/strings.xml
@@ -4052,6 +4052,8 @@ Try enclosing path to the editor with double quotes.
(e.g. "/path to my/editor" "%s")</string>
<string name="ExternalEditorCommandParseError">Error parsing the external editor command.</string>
<string name="ExternalEditorFailedToRun">External editor failed to run.</string>
+ <string name="ExternalEditorFailedToStart">Failed to start the WebSocket server. Ensure the ExternalWebsocketSyncEnable setting is on.</string>
+ <string name="VSCodeLaunchFailed">Failed to launch VS Code. Ensure the 'code' command is available on your PATH.</string>
<!-- Machine translation of chat messahes -->
<string name="TranslationFailed">Translation failed: [REASON]</string>