diff options
| author | Rider Linden <rider@lindenlab.com> | 2026-08-06 14:25:45 -0700 |
|---|---|---|
| committer | Rider Linden <rider@lindenlab.com> | 2026-08-06 14:25:45 -0700 |
| commit | 309fc5b4c21151e3664f5bdab99f57bae24266a0 (patch) | |
| tree | d7700857c86f8945d805b848494102cee299ac50 | |
| parent | ebf9cd31d789c0556d741987e95a961edd2955d0 (diff) | |
JSON-RPC command.execute instruction for arbitrary 1-off commands and extensions.
| -rw-r--r-- | doc/external-editor-json-rpc.md | 140 | ||||
| -rw-r--r-- | indra/newview/llscripteditorws.cpp | 128 | ||||
| -rw-r--r-- | indra/newview/llscripteditorws.h | 24 |
3 files changed, 292 insertions, 0 deletions
diff --git a/doc/external-editor-json-rpc.md b/doc/external-editor-json-rpc.md index a3404ce1eb..6c041cf70b 100644 --- a/doc/external-editor-json-rpc.md +++ b/doc/external-editor-json-rpc.md @@ -46,6 +46,9 @@ This document describes all the message interfaces defined for WebSocket communi - [ObjectRequest](#objectrequest) - [ObjectModify](#objectmodify) - [ObjectItemModify](#objectitemmodify) +- [Command Interfaces](#command-interfaces) + - [CommandExecute](#commandexecute) + - [CommandList](#commandlist) ## Usage Flow @@ -196,6 +199,10 @@ WebSocket connects -> session.handshake -> session.ok | `object.modify` (response) | Viewer -> Extension | Response | `ObjectModifyResponse` | | `object.item.modify` | Extension -> Viewer | Call | `ObjectItemModifyParams` | | `object.item.modify` (response) | Viewer -> Extension | Response | `ObjectItemModifyResponse` | +| `command.execute` | Bidirectional | Call | `CommandExecuteParams` | +| `command.execute` (response) | Bidirectional | Response | `CommandExecuteResponse` | +| `command.list` | Bidirectional | Call | _(no params)_ | +| `command.list` (response) | Bidirectional | Response | `CommandListResponse` | ## Session Management Interfaces @@ -235,6 +242,7 @@ interface SessionHandshake { - `live_sync`: Viewer supports live script synchronisation with the external editor - `compilation`: Viewer will forward compilation results via `script.compiled` - `syntax_cache`: Viewer supports `language.syntax.cache` and `language.syntax.get` for retrieving syntax definition files + - `commands`: Both sides support `command.execute` and `command.list` ### SessionHandshakeResponse @@ -1218,3 +1226,135 @@ interface ObjectItemModifyResponse { - An `object.update` notification will fire after successful modification. - Owner permissions cannot be modified directly — only `next_owner` can be changed. - If the item is renamed, the virtual filesystem path will change and the extension must handle the rename appropriately. + +--- + +## Command Interfaces + +These interfaces provide a general-purpose, bidirectional command channel. Either side may invoke a named command on the other side and receive a structured result. The feature is optional and must be negotiated via the `commands` flag in the session handshake. + +### CommandExecute + +**JSON-RPC Method:** `command.execute` (call, bidirectional) + +Invokes a named command on the receiving side. Commands are identified by a namespaced string and carry an optional freeform parameter map. + +```typescript +interface CommandExecuteParams { + command: string; // namespaced command id, e.g. "viewer.teleport" + params?: Record<string, unknown>; // command-specific arguments +} + +interface CommandExecuteResponse { + success: boolean; + result?: unknown; // optional command-specific return value + error_code?: number; + message?: string; +} +``` + +**Fields:** + +- `command`: Namespaced command identifier. The prefix before the first `.` identifies the side that owns and executes the command: + - `viewer.*` - commands executed by the viewer (e.g. `viewer.teleport`, `viewer.script.recompile_all`) + - `editor.*` - commands executed by the extension (e.g. `editor.open_file`, `editor.show_message`) +- `params` (optional): Command-specific argument map. Structure varies by command. +- `success`: Whether the command was found and executed without error. +- `result` (optional): Command-specific return value. Only present when `success` is `true` and the command produces output. +- `error_code` (optional): Numeric failure code. Only present when `success` is `false`: + - `1` - Unknown command + - `2` - Invalid or missing parameters + - `3` - Not permitted + - `4` - Execution error +- `message` (optional): Human-readable error description. Only present when `success` is `false`. + +**Capability gate:** A side MUST NOT send `command.execute` unless the peer advertised `commands: true` in the handshake. A receiver that receives the call without having negotiated the feature MUST respond with `success: false, error_code: 1`. + +**Example - extension asks viewer to teleport:** + +```json +{ + "jsonrpc": "2.0", + "method": "command.execute", + "id": 7, + "params": { + "command": "viewer.teleport", + "params": { "region": "Aditi", "position": [128, 128, 25] } + } +} +``` + +```json +{ + "jsonrpc": "2.0", + "id": 7, + "result": { "success": true } +} +``` + +**Example - viewer asks extension to show a message:** + +```json +{ + "jsonrpc": "2.0", + "method": "command.execute", + "id": 8, + "params": { + "command": "editor.show_message", + "params": { "message": "Script reset complete", "level": "info" } + } +} +``` + +--- + +### CommandList + +**JSON-RPC Method:** `command.list` (call, bidirectional) + +Requests the list of commands the receiving side supports. Intended for tooling and autocomplete; implementations may omit this method and return an error response if discovery is not needed. + +This method takes no parameters. + +**Response:** + +```typescript +interface CommandListResponse { + commands: CommandInfo[]; +} + +interface CommandInfo { + command: string; + description?: string; + params?: Record<string, CommandParamInfo>; +} + +interface CommandParamInfo { + type: "string" | "number" | "boolean" | "object" | "array"; + required?: boolean; + description?: string; +} +``` + +**Response Fields:** + +- `commands`: Array of commands the responder supports. Each entry describes one command. + - `command`: The namespaced command identifier. + - `description` (optional): Human-readable description of what the command does. + - `params` (optional): Map of parameter names to their type descriptors. + +**Known viewer commands:** + +| Command | Required params | Description | +|---------|----------------|-------------| +| `viewer.teleport` | `region: string` | Teleport agent to a region. Optional `position: [x, y, z]`. | +| `viewer.script.recompile_all` | `object_id: string` | Recompile all scripts in an object. | +| `viewer.script.reset_all` | `object_id: string` | Reset all scripts in an object. | +| `viewer.camera.focus` | `object_id: string` | Move camera focus to an in-world object. | + +**Known extension commands:** + +| Command | Required params | Description | +|---------|----------------|-------------| +| `editor.open_file` | `path: string` | Open a file in the editor. Optional `line: number`. | +| `editor.show_message` | `message: string` | Show a notification. Optional `level: "info" | "warn" | "error"`. | diff --git a/indra/newview/llscripteditorws.cpp b/indra/newview/llscripteditorws.cpp index 8f45d14c36..3fbcd5c384 100644 --- a/indra/newview/llscripteditorws.cpp +++ b/indra/newview/llscripteditorws.cpp @@ -31,6 +31,7 @@ #include "llscripteditorws.h" #include "llagent.h" +#include "llagentcamera.h" #include "llappviewer.h" #include "llchat.h" #include "lldate.h" @@ -61,6 +62,7 @@ #include "llviewerobject.h" #include "llviewerobjectlist.h" #include "llviewerregion.h" +#include "llviewermenu.h" #include "llviewertexteditor.h" #include "llvoinventorylistener.h" #include "roles_constants.h" @@ -166,6 +168,46 @@ LLScriptEditorWSServer::LLScriptEditorWSServer(const std::string& name, U16 port { LL_INFOS("ScriptEditorWS") << "Created JSON-RPC script editor server: " << name << " on port " << port << LL_ENDL; + + registerCommand({ "viewer.teleport", "Teleport agent to an in-world object" }, + [](U32, const LLSD& p) -> LLSD + { + LLUUID object_id = p["object_id"].asUUID(); + if (object_id.isNull()) + throw LLJSONRPCConnection::InvalidParams("object_id is required"); + + LLViewerObject* object = gObjectList.findObject(object_id); + if (!object) + throw LLJSONRPCConnection::InvalidParams("object_id not found"); + + LLVector3d global_pos = object->getPositionGlobal(); + gAgent.teleportViaLocation(global_pos); + + LLSD response; + response["success"] = true; + return response; + }); + + registerCommand({ "viewer.camera.focus", "Zoom camera to an in-world object (same behavior as context menu Zoom In)" }, + [](U32, const LLSD& p) -> LLSD + { + LLUUID object_id = p["object_id"].asUUID(); + if (object_id.isNull()) + throw LLJSONRPCConnection::InvalidParams("object_id is required"); + + if (!handle_zoom_to_object(object_id)) + { + LLSD response; + response["success"] = false; + response["error_code"] = WSCommandError::ExecutionError; + response["message"] = "Object not found or not reachable"; + return response; + } + + LLSD response; + response["success"] = true; + return response; + }); } LLScriptEditorWSServer::ptr_t LLScriptEditorWSServer::getServer() @@ -623,6 +665,18 @@ void LLScriptEditorWSServer::setupConnectionMethods(LLJSONRPCConnection::ptr_t c { return s.handleObjectItemModify(connection_id, params); })); + + script_connection->registerAsyncMethod("command.execute", + bindHandler([connection_id](LLScriptEditorWSServer& s, auto&, auto&, const LLSD& params) + { + return s.handleCommandExecute(connection_id, params); + })); + + script_connection->registerMethod("command.list", + bindHandler([](LLScriptEditorWSServer& s, auto&, auto&, auto&) + { + return s.handleCommandList(); + })); } } @@ -854,6 +908,79 @@ LLSD LLScriptEditorWSServer::handleObjectItemModify(U32 connection_id, const LLS return response; } +void LLScriptEditorWSServer::registerCommand(const WSCommandInfo& info, WSCommandHandler handler) +{ + mCommandRegistry.emplace(info.command, std::make_pair(info, std::move(handler))); +} + +bool LLScriptEditorWSConnection::hasFeature(const std::string& feature) const +{ + return mFeatures.count(feature) > 0; +} + +LLSD LLScriptEditorWSServer::handleCommandExecute(U32 connection_id, const LLSD& params) +{ + const std::string command = params["command"].asString(); + if (command.empty()) + { + throw LLJSONRPCConnection::InvalidParams("command is required"); + } + + auto it = mCommandRegistry.find(command); + if (it == mCommandRegistry.end()) + { + LLSD response; + response["success"] = false; + response["error_code"] = WSCommandError::UnknownCommand; + response["message"] = "Unknown command: " + command; + return response; + } + + return it->second.second(connection_id, params["params"]); +} + +LLSD LLScriptEditorWSServer::handleCommandList() +{ + LLSD commands(LLSD::emptyArray()); + for (const auto& [name, entry] : mCommandRegistry) + { + LLSD info; + info["command"] = entry.first.command; + info["description"] = entry.first.description; + commands.append(info); + } + LLSD response; + response["commands"] = commands; + return response; +} + +void LLScriptEditorWSServer::sendCommandExecute( + U32 connection_id, const std::string& command, const LLSD& params) +{ + auto it = mActiveConnections.find(connection_id); + if (it == mActiveConnections.end()) + { + return; + } + + auto connection = it->second.lock(); + if (!connection || !connection->hasFeature("commands")) + { + return; + } + + LLSD call_params; + call_params["command"] = command; + call_params["params"] = params; + + connection->call("command.execute", call_params, + [command](const LLSD& result, const LLSD& error) + { + LL_WARNS_IF(!error.isUndefined() || !result["success"].asBoolean(), "WSCommand") + << "command.execute failed for " << command << LL_ENDL; + }); +} + void LLScriptEditorWSServer::broadcastLanguageChange() { LLUUID syntax_id = LLSyntaxDefCache::instance().getSyntaxID(); @@ -2300,6 +2427,7 @@ void LLScriptEditorWSConnection::onOpen() features["live_sync"] = true; features["compilation"] = true; features["syntax_cache"] = true; + features["commands"] = true; handshake["features"] = features; wptr_t that = weak_from_this(); diff --git a/indra/newview/llscripteditorws.h b/indra/newview/llscripteditorws.h index 475a8049ee..63b60335ff 100644 --- a/indra/newview/llscripteditorws.h +++ b/indra/newview/llscripteditorws.h @@ -39,6 +39,7 @@ #include <map> #include <set> #include <atomic> +#include <functional> // Forward declarations class LLLiveLSLEditor; @@ -87,6 +88,7 @@ public: void onClose() override; void sendDisconnect(DisconnectReason reason = DisconnectReason::NORMAL, const std::string& message = "Goodbye"); + bool hasFeature(const std::string& feature) const; private: using string_set_t = std::set<std::string>; @@ -205,6 +207,8 @@ public: bool publishObject(const LLUUID& object_id); void unpublishObject(const LLUUID& object_id, const std::string& reason = ""); bool isObjectPublished(const LLUUID& object_id) const; + + // *TODO*: These should be moved to LLPublishedObjectMgr at some point. 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, S16 inventory_serial = -1); @@ -242,6 +246,9 @@ protected: 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 handleCommandExecute(U32 connection_id, const LLSD& params); + LLSD handleCommandList(); + void sendCommandExecute(U32 connection_id, const std::string& command, const LLSD& params); struct ValidatedItem { @@ -313,6 +320,23 @@ private: mutable LLPublishedObjectMgr mPublishedObjectManager; + struct WSCommandInfo + { + std::string command; + std::string description; + }; + enum WSCommandError + { + UnknownCommand = 1, + InvalidParams = 2, + NotPermitted = 3, + ExecutionError = 4, + }; + using WSCommandHandler = std::function<LLSD(U32 connection_id, const LLSD& params)>; + std::unordered_map<std::string, std::pair<WSCommandInfo, WSCommandHandler>> mCommandRegistry; + + void registerCommand(const WSCommandInfo& info, WSCommandHandler handler); + boost::signals2::connection mLanguageChangeSignal; LLUUID mLastSyntaxId; |
