diff --git a/doc/external-editor-json-rpc.md b/doc/external-editor-json-rpc.md index 9d355957fef..abaa14047d8 100644 --- a/doc/external-editor-json-rpc.md +++ b/doc/external-editor-json-rpc.md @@ -4,33 +4,45 @@ This document describes all the message interfaces defined for WebSocket communi ## Table of Contents -- [Usage Flow](#usage-flow) -- [JSON-RPC Method Summary](#json-rpc-method-summary) -- [Session Management Interfaces](#session-management-interfaces) - - [SessionHandshake](#sessionhandshake) - - [SessionHandshakeResponse](#sessionhandshakeresponse) - - [Session OK](#session-ok) - - [SessionDisconnect](#sessiondisconnect) -- [Language and Syntax Interfaces](#language-and-syntax-interfaces) - - [SyntaxChange](#syntaxchange) - - [Language Syntax ID Request](#language-syntax-id-request) - - [Language Syntax Request](#language-syntax-request) - - [Language Syntax Cache List](#language-syntax-cache-list) - - [Language Syntax Cache Get](#language-syntax-cache-get) -- [Script Subscription Interfaces](#script-subscription-interfaces) - - [ScriptSubscribe](#scriptsubscribe) - - [ScriptSubscribeResponse](#scriptsubscriberesponse) - - [ScriptUnsubscribe](#scriptunsubscribe) - - [ScriptList](#scriptlist) -- [Compilation Interfaces](#compilation-interfaces) - - [CompilationError](#compilationerror) - - [CompilationResult](#compilationresult) -- [Runtime Event Interfaces](#runtime-event-interfaces) - - [RuntimeDebug](#runtimedebug) - - [RuntimeError](#runtimeerror) -- [Handler and Configuration Interfaces](#handler-and-configuration-interfaces) - - [WebSocketHandlers](#websockethandlers) - - [ClientInfo](#clientinfo) +- [Usage Flow](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#usage-flow) +- [VS Code Launch URI](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#vs-code-launch-uri) +- [JSON-RPC Method Summary](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#json-rpc-method-summary) +- [Session Management Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#session-management-interfaces) + - [SessionHandshake](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#sessionhandshake) + - [SessionHandshakeResponse](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#sessionhandshakeresponse) + - [Session OK](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#session-ok) + - [SessionDisconnect](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#sessiondisconnect) +- [Language and Syntax Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-and-syntax-interfaces) + - [SyntaxChange](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#syntaxchange) + - [Language Syntax ID Request](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-id-request) + - [Language Syntax Request](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-request) + - [Language Syntax Cache List](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-cache-list) + - [Language Syntax Cache Get](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-cache-get) +- [Script Subscription Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#script-subscription-interfaces) + - [ScriptSubscribe](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptsubscribe) + - [ScriptSubscribeResponse](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptsubscriberesponse) + - [ScriptUnsubscribe](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptunsubscribe) + - [ScriptList](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptlist) +- [Compilation Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#compilation-interfaces) + - [CompilationError](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#compilationerror) + - [CompilationResult](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#compilationresult) +- [Runtime Event Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#runtime-event-interfaces) + - [RuntimeDebug](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#runtimedebug) + - [RuntimeError](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#runtimeerror) +- [Handler and Configuration Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#handler-and-configuration-interfaces) + - [WebSocketHandlers](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#websockethandlers) + - [ClientInfo](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#clientinfo) +- [Object Content Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#object-content-interfaces) + - [Core Data Types](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#core-data-types) + - [ObjectPublish](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectpublish) + - [ObjectUnpublish](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectunpublish) + - [ObjectUpdate](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectupdate) + - [ObjectContentGet](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectcontentget) + - [ObjectContentSave](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectcontentsave) + - [ObjectItemCreate](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectitemcreate) + - [ObjectItemDelete](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectitemdelete) + - [ObjectScriptSetRunning](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectscriptsetrunning) + - [ObjectRequest](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectrequest) ## Usage Flow @@ -53,42 +65,124 @@ This document describes all the message interfaces defined for WebSocket communi - When subscription needs to be terminated, viewer sends `script.unsubscribe` notification with `ScriptUnsubscribe` data - Extension handles unsubscription by cleaning up local script tracking -4. **Runtime Events:** +4. **Object Content Publishing:** + + - Viewer sends `object.publish` notification when an in-world object's contents are made available for editing + - Viewer sends `object.unpublish` notification when an object is removed or the owner stops publishing + - Viewer sends `object.update` notification when object inventory changes (full replacement or delta) + - Extension calls `object.content.get` to fetch an item's content on demand + - Extension calls `object.content.save` to write modified content back to the viewer + - Extension calls `object.item.create` / `object.item.delete` to manage inventory items + - Extension calls `object.script.set_running` to start or stop a script + +5. **Runtime Events:** - Viewer sends `language.syntax.change` notification with `SyntaxChange` when language changes - Viewer sends `script.compiled` notification with `CompilationResult` after script compilation - Viewer sends `runtime.debug` notification with `RuntimeDebug` for debug messages during script execution - Viewer sends `runtime.error` notification with `RuntimeError` when runtime errors occur -5. **Connection Termination:** +6. **Connection Termination:** - Either side can send `session.disconnect` notification with `SessionDisconnect` data - Connection is closed gracefully +## VS Code Launch URI + +The viewer can launch VS Code and trigger an automatic WebSocket connection by opening a `vscode://` URI via the operating system's default URI handler. The extension registers a URI handler for this scheme; VS Code will launch itself if not already running and deliver the URI to the extension. + +### URI Format + +``` +vscode://lindenlab.sl-vscode-plugin/connect[?port=][&object=][&script=] +``` + +### Parameters + +| Parameter | Required | Description | +| --------- | -------- | ----------- | +| `port` | No | Port number the viewer's WebSocket server is listening on. Overrides the user's configured port for this session. Defaults to the configured `slVscodeEdit.network.websocketPort` (default `9020`) if absent. Must be in range 1024-65535. | +| `object` | No | UUID of a root prim. After the handshake completes the extension calls `object.request` to ask the viewer to publish this object. The viewer then sends an `object.publish` notification and the object appears as a workspace folder in the Explorer. | +| `script` | No | UUID of a script. After the handshake completes the extension locates the corresponding temp file via `script.list` and opens it, triggering the normal `script.subscribe` + live-sync flow. | + +`object` and `script` are mutually exclusive in typical use but both may be supplied; the extension will process both. + +### Examples + +``` +# Open VS Code and connect on default port +vscode://lindenlab.sl-vscode-plugin/connect + +# Connect on a custom port +vscode://lindenlab.sl-vscode-plugin/connect?port=9021 + +# Connect and immediately publish a specific object +vscode://lindenlab.sl-vscode-plugin/connect?port=9020&object=550e8400-e29b-41d4-a716-446655440000 + +# Connect and open a specific script for editing +vscode://lindenlab.sl-vscode-plugin/connect?port=9020&script=6ba7b810-9dad-11d1-80b4-00c04fd430c8 +``` + +### Post-connection sequence + +When the URI contains an `object` or `script` parameter the extension acts only **after** the handshake is fully complete (`session.ok` received): + +``` +URI received by extension + | + v +WebSocket connects -> session.handshake -> session.ok + | + |- object= -> object.request({ object_id }) call + | | + | v (async, when viewer is ready) + | object.publish notification + | + \- script= -> script.list call -> open temp file + | + v + script.subscribe + live-sync +``` + ## JSON-RPC Method Summary | Method | Direction | Type | Interface/Parameters | | ------------------------------- | ------------------ | ------------ | -------------------------- | -| `session.handshake` | Viewer → Extension | Call | `SessionHandshake` | -| `session.handshake` (response) | Extension → Viewer | Response | `SessionHandshakeResponse` | -| `session.ok` | Viewer → Extension | Notification | _(no interface)_ | +| `session.handshake` | Viewer -> Extension | Call | `SessionHandshake` | +| `session.handshake` (response) | Extension -> Viewer | Response | `SessionHandshakeResponse` | +| `session.ok` | Viewer -> Extension | Notification | _(no interface)_ | | `session.disconnect` | Bidirectional | Notification | `SessionDisconnect` | -| `script.subscribe` | Extension → Viewer | Call | `ScriptSubscribe` | -| `script.subscribe` (response) | Viewer → Extension | Response | `ScriptSubscribeResponse` | -| `script.unsubscribe` | Viewer → Extension | Notification | `ScriptUnsubscribe` | -| `script.list` | Extension → Viewer | Call | _(no parameters)_ | -| `script.list` (response) | Viewer → Extension | Response | `ScriptList` | -| `language.syntax.id` | Extension → Viewer | Call | _(no parameters)_ | -| `language.syntax.id` (response) | Viewer → Extension | Response | `{ id: string }` | -| `language.syntax` | Extension → Viewer | Call | `{ kind: string }` | -| `language.syntax` (response) | Viewer → Extension | Response | `LanguageInfo` | -| `language.syntax.cache` | Extension → Viewer | Call | _(no parameters)_ | -| `language.syntax.cache` (response) | Viewer → Extension | Response | `SyntaxCacheList` | -| `language.syntax.get` | Extension → Viewer | Call | `{ filename: string, as_json?: boolean }` | -| `language.syntax.get` (response) | Viewer → Extension | Response | `SyntaxCacheFile` | -| `language.syntax.change` | Viewer → Extension | Notification | `SyntaxChange` | -| `script.compiled` | Viewer → Extension | Notification | `CompilationResult` | -| `runtime.debug` | Viewer → Extension | Notification | `RuntimeDebug` | -| `runtime.error` | Viewer → Extension | Notification | `RuntimeError` | +| `script.subscribe` | Extension -> Viewer | Call | `ScriptSubscribe` | +| `script.subscribe` (response) | Viewer -> Extension | Response | `ScriptSubscribeResponse` | +| `script.unsubscribe` | Viewer -> Extension | Notification | `ScriptUnsubscribe` | +| `script.list` | Extension -> Viewer | Call | _(no parameters)_ | +| `script.list` (response) | Viewer -> Extension | Response | `ScriptList` | +| `language.syntax.id` | Extension -> Viewer | Call | _(no parameters)_ | +| `language.syntax.id` (response) | Viewer -> Extension | Response | `{ id: string }` | +| `language.syntax` | Extension -> Viewer | Call | `{ kind: string }` | +| `language.syntax` (response) | Viewer -> Extension | Response | `LanguageInfo` | +| `language.syntax.cache` | Extension -> Viewer | Call | _(no parameters)_ | +| `language.syntax.cache` (response) | Viewer -> Extension | Response | `SyntaxCacheList` | +| `language.syntax.get` | Extension -> Viewer | Call | `{ filename: string, as_json?: boolean }` | +| `language.syntax.get` (response) | Viewer -> Extension | Response | `SyntaxCacheFile` | +| `language.syntax.change` | Viewer -> Extension | Notification | `SyntaxChange` | +| `script.compiled` | Viewer -> Extension | Notification | `CompilationResult` | +| `runtime.debug` | Viewer -> Extension | Notification | `RuntimeDebug` | +| `runtime.error` | Viewer -> Extension | Notification | `RuntimeError` | +| `object.publish` | Viewer -> Extension | Notification | `ObjectPublishMessage` | +| `object.unpublish` | Viewer -> Extension | Notification | `ObjectUnpublishMessage` | +| `object.update` | Viewer -> Extension | Notification | `ObjectUpdateMessage` | +| `object.content.get` | Extension -> Viewer | Call | `ObjectContentGetParams` | +| `object.content.get` (response) | Viewer -> Extension | Response | `ObjectContentGetResponse` | +| `object.content.save` | Extension -> Viewer | Call | `ObjectContentSaveParams` | +| `object.content.save` (response)| Viewer -> Extension | Response | `ObjectContentSaveResponse`| +| `object.item.create` | Extension -> Viewer | Call | `ObjectItemCreateParams` | +| `object.item.create` (response) | Viewer -> Extension | Response | `ObjectItemCreateResponse` | +| `object.item.delete` | Extension -> Viewer | Call | `ObjectItemDeleteParams` | +| `object.item.delete` (response) | Viewer -> Extension | Response | `ObjectItemDeleteResponse` | +| `object.script.set_running` | Extension -> Viewer | Call | `ObjectScriptSetRunningParams` | +| `object.script.set_running` (response) | Viewer -> Extension | Response | `ObjectScriptSetRunningResponse` | +| `object.request` | Extension -> Viewer | Call | `ObjectRequestParams` | +| `object.request` (response) | Viewer -> Extension | Response | `ObjectRequestResponse` | ## Session Management Interfaces @@ -293,7 +387,7 @@ interface SyntaxCacheList { | `lua_keywords_pretty.xml` | Luau keyword definitions in formatted LLSD XML format | | `secondlife_selene.yml` | Luau Selene linter configuration in YAML format | -Not all files may be present in every cache — the actual list returned by `language.syntax.cache` reflects only what is available on the viewer's local filesystem at the time of the request. +Not all files may be present in every cache - the actual list returned by `language.syntax.cache` reflects only what is available on the viewer's local filesystem at the time of the request. ### Language Syntax Cache Get @@ -382,9 +476,9 @@ interface ScriptSubscribeResponse { - `success`: Whether the subscription was successful - `status`: Numeric status code indicating the result: - `0`: Success - - `1`: Invalid editor — the script editor panel is no longer open - - `2`: Invalid subscription — no subscription found for the given `script_id` - - `3`: Already subscribed — another connection is already subscribed to this script + - `1`: Invalid editor - the script editor panel is no longer open + - `2`: Invalid subscription - no subscription found for the given `script_id` + - `3`: Already subscribed - another connection is already subscribed to this script - `4`: Internal server error - `object_id` (optional): The in-world UUID of the object containing the script - `item_id` (optional): The inventory item UUID of the script within the object @@ -522,7 +616,7 @@ interface RuntimeError { - `object_id`: Unique identifier for the object containing the script - `object_name`: Human-readable name of the object - `message`: The full raw chat text of the runtime error message as received from the simulator -- `error`: Extracted error description. Currently always an empty string — runtime error extraction from the simulator's multi-message format is not yet fully implemented. +- `error`: Extracted error description. Currently always an empty string - runtime error extraction from the simulator's multi-message format is not yet fully implemented. - `line`: Line number where the error occurred. Currently always `0` for the same reason. - `stack` (optional): Stack trace lines if they could be extracted from the error message @@ -578,3 +672,326 @@ interface ClientInfo { - `scriptId`: Unique identifier for the script - `extension`: File extension or script type +--- + +## Object Content Interfaces + +These interfaces support publishing in-world object inventories (scripts and notecards) to the external editor as a browseable virtual filesystem. The extension exposes published objects under the `sl://objects/` URI scheme. + +### Core Data Types + +```typescript +type InventoryItemType = "script" | "notecard"; + +type ScriptVM = "lsl2" | "mono" | "luau"; + +/** Permission mask fields. Only owner and next_owner are transmitted. */ +interface ItemPermissions { + owner: number; // e.g. PERM_MODIFY=0x4000, PERM_COPY=0x8000, PERM_TRANSFER=0x2000 + next_owner: number; +} + +/** + * Inventory item within an object or linked prim. + * asset_id is intentionally never transmitted. + */ +interface ObjectInventoryItem { + item_id: string; // Inventory item UUID + name: string; // Display name (no file extension) + description?: string; + type: InventoryItemType; + subtype?: number; // Scripts only: language from II_FLAGS_SUBTYPE_MASK (0=LSL, 1=Luau) + vm?: ScriptVM; // Scripts only: which VM the script targets + running?: boolean; // Scripts only: whether the script is running + permissions?: ItemPermissions; + creator_id?: string; +} + +/** A linked (child) prim within a linkset */ +interface LinkedObject { + link_id: string; // UUID of the linked prim + link_number: number; // Link number (root=1, children>=2) + link_name: string; + link_description?: string; + inventory: ObjectInventoryItem[]; +} + +interface ObjectPermissions { + owner: number; + next_owner: number; +} + +/** Root of a linkset, as published to the extension */ +interface PublishedObject { + object_id: string; // UUID of the root prim + object_name: string; + object_description?: string; + region?: string; + owner_id?: string; + permissions?: ObjectPermissions; + inventory: ObjectInventoryItem[]; // Root prim's scripts and notecards + linked_objects?: LinkedObject[]; // Child prims +} +``` + +**Script display extensions** (synthetic, derived from `subtype`): + +| `subtype` | Extension | +| --------- | --------- | +| `0` (LSL) | `.lsl` | +| `1` (Luau)| `.luau` | +| notecard | `.txt` | + +--- + +### ObjectPublish + +**JSON-RPC Method:** `object.publish` (notification from viewer) + +Sent when the viewer publishes an in-world object's inventory for external editing. Triggers creation of a virtual filesystem workspace folder in the extension. + +```typescript +interface ObjectPublishMessage { + object: PublishedObject; +} +``` + +**Fields:** + +- `object`: The full published object tree, including root prim inventory and all linked prim inventories. + +--- + +### ObjectUnpublish + +**JSON-RPC Method:** `object.unpublish` (notification from viewer) + +Sent when the viewer removes a previously published object - for example when the owner deselects it, moves away, or the object is deleted. + +```typescript +interface ObjectUnpublishMessage { + object_id: string; + reason?: string; +} +``` + +**Fields:** + +- `object_id`: UUID of the root prim that is being unpublished +- `reason` (optional): Human-readable explanation (e.g. `"object deleted"`, `"out of range"`) + +--- + +### ObjectUpdate + +**JSON-RPC Method:** `object.update` (notification from viewer) + +Sent when the inventory of a published object changes. Supports two modes: +- **Full replacement**: `inventory` and/or `linked_objects` fields replace the entire prior state. +- **Delta update**: `changes` field describes only what changed. Takes precedence over full replacement fields when present. + +```typescript +interface InventoryChanges { + added?: ObjectInventoryItem[]; + removed?: string[]; // item_ids removed + modified?: ObjectInventoryItem[]; // metadata-only changes + content_changed?: string[]; // item_ids whose content changed (invalidates cache) + running_changed?: { item_id: string; running: boolean }[]; // running state toggled +} + +interface LinkedObjectChanges { + added?: LinkedObject[]; + removed?: string[]; // link_ids removed + modified?: { + link_id: string; + link_name?: string; + inventory?: InventoryChanges; + }[]; +} + +interface ObjectUpdateMessage { + object_id: string; + object_name?: string; + // Full replacement (used when changes is absent) + inventory?: ObjectInventoryItem[]; + linked_objects?: LinkedObject[]; + // Delta (takes precedence when present) + changes?: { + inventory?: InventoryChanges; + linked_objects?: LinkedObjectChanges; + }; +} +``` + +--- + +### ObjectContentGet + +**JSON-RPC Method:** `object.content.get` (call from extension to viewer) + +Requests the text content of a script or notecard. The extension calls this lazily when the user opens a file in the virtual filesystem. + +```typescript +interface ObjectContentGetParams { + prim_id: string; // UUID of any prim (root or child) - no object_id + link_id needed + item_id: string; +} + +interface ObjectContentGetResponse { + success: boolean; + prim_id: string; + item_id: string; + content: string; // Raw text content (UTF-8). Notecard envelope is unwrapped automatically. +} +``` + +**Fields:** + +- `prim_id`: UUID of the prim that owns the item. Child prims are addressable directly by UUID without knowing the root object_id. +- `item_id`: Inventory item UUID. +- `success`: `true` on success. +- `content`: The raw text content of the item. For notecards, the `Linden text version 2` envelope is stripped - only the body text is returned. + +--- + +### ObjectContentSave + +**JSON-RPC Method:** `object.content.save` (call from extension to viewer) + +Writes modified content back to the viewer. For scripts, the viewer will attempt to compile the updated source. + +```typescript +interface ObjectContentSaveParams { + prim_id: string; + item_id: string; + content: string; + vm?: "mono" | "lsl2" | "luau"; +} + +interface ObjectContentSaveResponse { + success: boolean; + prim_id?: string; + item_id?: string; + compiled?: boolean; + errors?: string[]; + message?: string; +} +``` + +**Fields:** + +- `prim_id`: UUID of the prim that owns the saved item. +- `item_id`: UUID of the saved inventory item. +- `content`: Raw script/notecard source text to store. +- `vm` (optional): Scripts only compile target. Accepted values are `"mono"`, `"lsl2"`, `"luau"`. When `"luau"` is specified for an LSL script (as opposed to a native Luau script), the viewer automatically selects the correct LSL-on-Luau compile path. If omitted, inferred from item metadata or content analysis. +- `success`: Whether the upload/save operation succeeded. +- `compiled` (optional): Scripts only. `true` when compilation succeeded, `false` when source saved but compile failed. +- `errors` (optional): Scripts only. Compiler diagnostics when `compiled` is `false`. +- `message` (optional): Error description on failure. + +--- + +### ObjectItemCreate + +**JSON-RPC Method:** `object.item.create` (call from extension to viewer) + +Creates a new script in a prim's inventory. The call is asynchronous - the viewer sends +`RezScript` to the simulator and waits for the inventory-changed callback before returning +the created item's details. The simulator may rename the item if a duplicate name exists. + +Notecard creation is not yet supported and will return an error. + +```typescript +interface ObjectItemCreateParams { + prim_id: string; // UUID of the prim to create the item in + name: string; // Pure SL inventory name - no file extension + type: InventoryItemType; // "script" ("notecard" reserved for future) + vm: ScriptVM; // Required for scripts: "luau" | "mono" | "lsl2" +} + +// On success, returns an ObjectInventoryItem with prim_id: +interface ObjectItemCreateResponse extends ObjectInventoryItem { + prim_id: string; // Echoed prim UUID +} +``` + +**Notes:** +- The response matches the `ObjectInventoryItem` structure (same fields as items in + `object.publish` and `object.update` notifications). +- The `name` in the response may differ from the request if the simulator renamed it. +- An `object.update` notification will also fire for the prim (since inventory changed). +- Timeout: 30 seconds. Returns a JSON-RPC internal error if the simulator does not respond. + +--- + +### ObjectItemDelete + +**JSON-RPC Method:** `object.item.delete` (call from extension to viewer) + +Deletes a script or notecard from a prim's inventory. Requires `PERM_MODIFY` on the item. + +```typescript +interface ObjectItemDeleteParams { + prim_id: string; + item_id: string; +} + +interface ObjectItemDeleteResponse { + success: boolean; + prim_id: string; // Echoed back from request + item_id: string; // Echoed back from request +} +``` + +--- + +### ObjectScriptSetRunning + +**JSON-RPC Method:** `object.script.set_running` (call from extension to viewer) + +Starts or stops a script within a prim. + +```typescript +interface ObjectScriptSetRunningParams { + prim_id: string; + item_id: string; + running: boolean; // true = start, false = stop +} + +interface ObjectScriptSetRunningResponse { + success: boolean; + message?: string; +} +``` + +--- + +### ObjectRequest + +**JSON-RPC Method:** `object.request` (call from extension to viewer) + +Requests the viewer to publish a specific in-world object. The viewer responds synchronously to confirm the request was accepted, then asynchronously sends an `object.publish` notification with the full object tree. + +This is typically called immediately after the handshake completes when the extension was launched by the viewer with an `object=` URI parameter. + +```typescript +interface ObjectRequestParams { + object_id: string; // UUID of the root prim to request publishing for +} + +interface ObjectRequestResponse { + success: boolean; + message?: string; // reason on failure (e.g. "object not found", "permission denied") +} +``` + +**Fields:** + +- `object_id`: UUID of the root prim of the linkset to publish. +- `success`: Whether the viewer accepted the request. A `true` response does not mean `object.publish` has been sent yet - it means the viewer will send it. +- `message` (optional): Human-readable failure reason. Only present when `success` is `false`. + +**Sequence:** +1. Extension calls `object.request` +2. Viewer responds with `{ success: true }` (or error) +3. Viewer sends `object.publish` notification (asynchronously, when ready) diff --git a/indra/llcorehttp/lljsonrpcws.cpp b/indra/llcorehttp/lljsonrpcws.cpp index 93e38a8397c..f1c8ca2065f 100644 --- a/indra/llcorehttp/lljsonrpcws.cpp +++ b/indra/llcorehttp/lljsonrpcws.cpp @@ -30,6 +30,8 @@ #include "llerror.h" #include "llsdjson.h" #include "lldate.h" +#include "llcoros.h" +#include "llmainthreadtask.h" #include @@ -153,7 +155,44 @@ void LLJSONRPCConnection::processRequest(const LLSD& request) LL_DEBUGS("JSONRPC") << "Processing " << (is_notification ? "notification" : "request") << " for method: " << method << LL_ENDL; - // Find method handler + // Check async handlers first — launched as a coroutine, response sent by the lambda + auto async_it = mAsyncMethodHandlers.find(method); + if (async_it != mAsyncMethodHandlers.end()) + { + if (is_notification) + { + LL_WARNS("JSONRPC") << "Async method " << method + << " called as notification; ignoring" << LL_ENDL; + return; + } + ptr_t conn = std::static_pointer_cast(getSelfPtr()); + MethodHandler handler = async_it->second; + LLMainThreadTask::dispatch( + [handler, method, id, params, conn]() + { + LLCoros::instance().launch( + "JSONRPC::" + method, + [handler, method, id, params, conn]() + { + try + { + LLSD result = handler(method, id, params); + conn->sendResponse(id, result); + } + catch (const RPCError& e) + { + conn->sendError(id, e); + } + catch (const std::exception& e) + { + conn->sendError(id, InternalError(e.what())); + } + }); + }); + return; + } + + // Find sync method handler auto it = mMethodHandlers.find(method); if (it == mMethodHandlers.end()) { @@ -321,9 +360,16 @@ void LLJSONRPCConnection::registerMethod(const std::string& method, MethodHandle LL_DEBUGS("JSONRPC") << "Registered method: " << method << LL_ENDL; } +void LLJSONRPCConnection::registerAsyncMethod(const std::string& method, MethodHandler handler) +{ + mAsyncMethodHandlers[method] = handler; + LL_DEBUGS("JSONRPC") << "Registered async method: " << method << LL_ENDL; +} + void LLJSONRPCConnection::unregisterMethod(const std::string& method) { mMethodHandlers.erase(method); + mAsyncMethodHandlers.erase(method); LL_DEBUGS("JSONRPC") << "Unregistered method: " << method << LL_ENDL; } diff --git a/indra/llcorehttp/lljsonrpcws.h b/indra/llcorehttp/lljsonrpcws.h index bd9939aa339..71ea9159c0b 100644 --- a/indra/llcorehttp/lljsonrpcws.h +++ b/indra/llcorehttp/lljsonrpcws.h @@ -249,6 +249,19 @@ class LLJSONRPCConnection : public LLWebsocketMgr::WSConnection */ 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 @@ -338,6 +351,7 @@ class LLJSONRPCConnection : public LLWebsocketMgr::WSConnection private: std::unordered_map mMethodHandlers; + std::unordered_map mAsyncMethodHandlers; std::unordered_map mPendingRequests; }; diff --git a/indra/llcorehttp/llwebsocketmgr.cpp b/indra/llcorehttp/llwebsocketmgr.cpp index ea87d1e07e5..7f3d2ea969c 100644 --- a/indra/llcorehttp/llwebsocketmgr.cpp +++ b/indra/llcorehttp/llwebsocketmgr.cpp @@ -441,6 +441,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 @@ -672,3 +675,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 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 4165b3cecc4..2c335307e39 100644 --- a/indra/llcorehttp/llwebsocketmgr.h +++ b/indra/llcorehttp/llwebsocketmgr.h @@ -152,6 +152,10 @@ class LLWebsocketMgr: public LLSingleton 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 mOwningServer; // Back-reference to the server this connection belongs to }; @@ -260,6 +264,7 @@ class LLWebsocketMgr: public LLSingleton * 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 >; diff --git a/indra/newview/app_settings/settings.xml b/indra/newview/app_settings/settings.xml index f7c41873bb5..648e20fddaf 100644 --- a/indra/newview/app_settings/settings.xml +++ b/indra/newview/app_settings/settings.xml @@ -14155,6 +14155,17 @@ Value 1 + ExternalEditorTightIntegration + + Comment + When true, Edit in External Editor launches VS Code via the code CLI with a vscode:// URI instead of using the configured external editor. + Persist + 1 + Type + Boolean + Value + 0 + ExternalWebsocketForwardDebug Comment diff --git a/indra/newview/llpreviewscript.cpp b/indra/newview/llpreviewscript.cpp index 544473ba77c..e94ca831c4b 100644 --- a/indra/newview/llpreviewscript.cpp +++ b/indra/newview/llpreviewscript.cpp @@ -1152,10 +1152,38 @@ void LLScriptEdCore::openInExternalEditor() mContainer->mLiveFile = new LLLiveLSLFile(filename, boost::bind(&LLScriptEdContainer::onExternalChange, mContainer, _1)); mContainer->mLiveFile->addToEventTimer(); } - mContainer->startWebsocketServer(); + if (gSavedSettings.getBOOL("ExternalEditorTightIntegration")) + { + // VS Code tight integration path + auto server = LLScriptEditorWSServer::ensureServerRunning(); + if (server) + { + std::string script_id_hash_str(mContainer->getUniqueHash()); + server->subscribeScriptEditor(mContainer->mObjectUUID, mContainer->mItemUUID, + mScriptName, mContainer->getHandle(), script_id_hash_str); + mContainer->mWebSocketServer = server; + + LLViewerObject* object = gObjectList.findObject(mContainer->mObjectUUID); + LLViewerObject* root_object = object ? object->getRootEdit() : nullptr; + LLUUID root_id = root_object ? root_object->getID() : mContainer->mObjectUUID; - // Open it in external editor. + if (!LLScriptEditorWSServer::launchVSCode(root_id)) + { + LLNotificationsUtil::add("GenericAlert", + LLSD().with("MESSAGE", LLTrans::getString("VSCodeLaunchFailed"))); + } + } + else + { + LLNotificationsUtil::add("GenericAlert", + LLSD().with("MESSAGE", LLTrans::getString("ExternalEditorFailedToStart"))); + } + } + else { + // Legacy external editor path + mContainer->startWebsocketServer(); + LLExternalEditor ed; LLExternalEditor::EErrorCode status; std::string msg; @@ -1680,38 +1708,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( - wsmgr.findServerByName(LLScriptEditorWSServer::DEFAULT_SERVER_NAME)); - - if (!server) - { // We couldn't find one, so create it - U16 server_port = static_cast(gSavedSettings.getS32("ExternalWebsocketSyncPort")); - bool server_localhost = gSavedSettings.getBOOL("ExternalWebsocketSyncLocal"); - server = std::make_shared(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 3ca9be44bcd..cc33fc7e198 100644 --- a/indra/newview/llscripteditorws.cpp +++ b/indra/newview/llscripteditorws.cpp @@ -41,6 +41,66 @@ #include "llviewerobject.h" #include "llviewerobjectlist.h" #include "llchat.h" +#include "llviewercontrol.h" +#include "llprocess.h" +#include "llvoinventorylistener.h" +#include "llviewerregion.h" +#include "llselectmgr.h" +#include "llevents.h" +#include "lleventcoro.h" +#include "lleventfilter.h" +#include "llviewerassetstorage.h" +#include "llfilesystem.h" +#include "roles_constants.h" +#include "llviewerassetupload.h" +#include "llnotecard.h" +#include "llpreviewnotecard.h" +#include "llinventorytype.h" +#include "llfloaterreg.h" +#include "llviewertexteditor.h" +#include "llfloaterperms.h" +#include "llviewerassettype.h" +#include "llviewerinventory.h" + +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 +121,100 @@ LLScriptEditorWSServer::ptr_t LLScriptEditorWSServer::getServer() wsmgr.findServerByName(LLScriptEditorWSServer::DEFAULT_SERVER_NAME)); } +LLScriptEditorWSServer::ptr_t LLScriptEditorWSServer::ensureServerRunning() +{ + if (!gSavedSettings.getBOOL("ExternalWebsocketSyncEnable")) + { + LL_DEBUGS("ScriptEditorWS") << "WebSocket server is disabled by ExternalWebsocketSyncEnable" << LL_ENDL; + return nullptr; + } + + LLWebsocketMgr& wsmgr = LLWebsocketMgr::instance(); + ptr_t server = std::static_pointer_cast( + wsmgr.findServerByName(DEFAULT_SERVER_NAME)); + + if (!server) + { + U16 port = static_cast(gSavedSettings.getS32("ExternalWebsocketSyncPort")); + bool local_only = gSavedSettings.getBOOL("ExternalWebsocketSyncLocal"); + server = std::make_shared(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(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 +249,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) @@ -119,6 +293,7 @@ void LLScriptEditorWSServer::onConnectionClosed(const LLWebsocketMgr::WSConnecti { U32 connection_id = script_connection->getConnectionID(); unsubscribeConnection(connection_id); + unpublishConnection(connection_id); mActiveConnections.erase(connection_id); LL_DEBUGS("ScriptEditorWS") << "Removed connection from active connections. Total: " @@ -319,7 +494,41 @@ void LLScriptEditorWSServer::setupConnectionMethods(LLJSONRPCConnection::ptr_t c } return LLSD(); }); - // script_connection->registerMethod("language.syntax", ) + script_connection->registerAsyncMethod("object.request", + [that, connection_id](const std::string& method, const LLSD& id, const LLSD& params) -> LLSD + { + auto server = that.lock(); + if (!server) return LLSD(); + return server->handleObjectRequest(connection_id, params); + }); + script_connection->registerAsyncMethod("object.content.get", + [that](const std::string& method, const LLSD& id, const LLSD& params) -> LLSD + { + auto server = that.lock(); + if (!server) return LLSD(); + return server->handleObjectContentGet(method, id, params); + }); + script_connection->registerAsyncMethod("object.content.save", + [that](const std::string& method, const LLSD& id, const LLSD& params) -> LLSD + { + auto server = that.lock(); + if (!server) return LLSD(); + return server->handleObjectContentSave(method, id, params); + }); + script_connection->registerMethod("object.item.delete", + [that, connection_id](const std::string&, const LLSD&, const LLSD& params) -> LLSD + { + auto server = that.lock(); + if (!server) return LLSD(); + return server->handleObjectItemDelete(connection_id, params); + }); + script_connection->registerAsyncMethod("object.item.create", + [that](const std::string& method, const LLSD& id, const LLSD& params) -> LLSD + { + auto server = that.lock(); + if (!server) return LLSD(); + return server->handleObjectItemCreate(method, id, params); + }); } } @@ -525,6 +734,506 @@ 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, connection_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(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) && + !gAgent.allowOperation(PERM_MODIFY, item->getPermissions(), GP_OBJECT_MANIPULATE)) + throw LLJSONRPCConnection::ForbiddenError("Insufficient permissions"); + + return { prim, root, item, type }; +} + +LLSD LLScriptEditorWSServer::handleObjectContentGet(const std::string& method, const LLSD& id, const LLSD& params) +{ + auto v = validatePublishedItem(params, PERM_COPY | PERM_MODIFY); + + LLUUID prim_id = params["prim_id"].asUUID(); + LLUUID item_id = params["item_id"].asUUID(); + + // Use LLEventMailDrop so that if the callback fires synchronously (cache hit) + // before suspendUntilEventOnWithTimeout registers its listener, the event is + // queued and replayed when the listener attaches -- no race condition. + LLEventMailDrop result_pump("objectContentGet." + LLUUID::generateNewID().asString(), true); + std::string pump_name = result_pump.getName(); + + 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(asset_type); + } + else + { + result["error"] = status; + } + LLEventPumps::instance().post(pump_name, result); + }, + nullptr, + true); + + LLSD cb_result = llcoro::suspendUntilEventOnWithTimeout( + result_pump, 30.0f, LLSD().with("timeout", true)); + + if (cb_result.has("timeout")) + throw LLJSONRPCConnection::RequestTimeoutError("Asset fetch timed out"); + + 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(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 buffer(file_length + 1); + file.read(reinterpret_cast(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()); // c-string ctor stops at first null + } + + 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"); + + LLEventMailDrop result_pump("objectContentSave." + LLUUID::generateNewID().asString(), true); + std::string pump_name = result_pump.getName(); + + LLResourceUploadInfo::ptr_t uploadInfo(std::make_shared( + prim->getID(), item->getUUID(), + compile_target, false, LLUUID::null, content, + [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); + }, + [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; + })); + + LLViewerAssetUpload::EnqueueInventoryUpload(url, uploadInfo); + + LLSD cb_result = llcoro::suspendUntilEventOnWithTimeout( + result_pump, 60.0f, LLSD().with("timeout", true)); + + if (cb_result.has("timeout")) + throw LLJSONRPCConnection::RequestTimeoutError("Script upload/compile timed out"); + + 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("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); + + LLEventMailDrop result_pump("objectContentSaveNotecard." + LLUUID::generateNewID().asString(), true); + std::string pump_name = result_pump.getName(); + + LLResourceUploadInfo::ptr_t uploadInfo(std::make_shared( + prim->getID(), item->getUUID(), + LLAssetType::AT_NOTECARD, ostr.str(), + [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); + }, + [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; + })); + + LLViewerAssetUpload::EnqueueInventoryUpload(url, uploadInfo); + + LLSD cb_result = llcoro::suspendUntilEventOnWithTimeout( + result_pump, 30.0f, LLSD().with("timeout", true)); + + if (cb_result.has("timeout")) + throw LLJSONRPCConnection::RequestTimeoutError("Notecard upload timed out"); + + 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("preview_notecard", floater_key); + if (nc) + { + LLViewerTextEditor* nc_editor = nc->getChild("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::handleObjectItemCreate(const std::string& method, const LLSD& id, const LLSD& params) +{ + std::string type = params["type"].asString(); + if (type == "notecard") + throw LLJSONRPCConnection::InvalidParams("Notecard creation not yet supported"); + if (type != "script") + 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"); + + std::string vm = params["vm"].asString(); + U8 script_language; + if (vm == "luau") + script_language = SST_LUA; + else if (vm == "mono" || vm == "lsl2") + script_language = SST_LSL; + else + throw LLJSONRPCConnection::InvalidParams("vm must be 'luau', 'mono', or 'lsl2'"); + + // Snapshot existing item IDs before creation + std::set existing_items; + { + LLInventoryObject::object_list_t inv; + prim->getInventoryContents(inv); + for (auto& obj : inv) + { + existing_items.insert(obj->getUUID()); + } + } + + // Set up event pump to wait for inventory change + LLEventMailDrop result_pump("objectItemCreate." + LLUUID::generateNewID().asString(), true); + std::string pump_name = result_pump.getName(); + mPendingItemCreates[prim_id] = pump_name; + + // Build permissions and create the item + LLPermissions perms; + perms.init(gAgent.getID(), gAgent.getID(), LLUUID::null, LLUUID::null); + perms.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); + + LLPointer new_item = + new LLViewerInventoryItem( + LLUUID::null, + LLUUID::null, + perms, + LLUUID::null, + LLAssetType::AT_LSL_TEXT, + LLInventoryType::IT_LSL, + name, + desc, + LLSaleInfo::DEFAULT, + LLInventoryItemFlags::II_FLAGS_SUBTYPE_MASK & script_language, + time_corrected()); + + prim->saveScript(new_item, true, true, LLUUID::null); + + // Wait for inventory change callback (timeout 30s) + LLSD event = llcoro::suspendUntilEventOnWithTimeout(result_pump, 30.0f, LLSD().with("timeout", true)); + + if (event.has("timeout")) + { + mPendingItemCreates.erase(prim_id); + throw LLJSONRPCConnection::InternalError("Timed out waiting for script creation"); + } + + // Re-validate prim and find the new item by diffing + prim = gObjectList.findObject(prim_id); + if (!prim) + throw LLJSONRPCConnection::InternalError("Prim no longer exists"); + + LLSD response; + { + 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(obj.get()); + if (created && created->getType() == LLAssetType::AT_LSL_TEXT) + { + response["item_id"] = created->getUUID(); + response["name"] = created->getName(); + response["description"] = created->getDescription(); + response["type"] = "script"; + + U8 subtype = created->getInventorySubType(); + response["subtype"] = static_cast(subtype); + + 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(item_perms.getMaskOwner()); + perm_entry["next_owner"] = static_cast(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("Script was not found in updated inventory"); + + return response; +} + + void LLScriptEditorWSServer::notifyScript(const std::string& script_id, const std::string &method, const LLSD& message) const { auto it = mSubscriptions.find(script_id); @@ -763,6 +1472,377 @@ void LLScriptEditorWSServer::forwardChatToIDE(const LLChat& chat_msg) const } } +void LLScriptEditorWSServer::notifyConnection(U32 connection_id, const std::string& method, const LLSD& params) const +{ + auto it = mActiveConnections.find(connection_id); + if (it != mActiveConnections.end()) + { + auto connection = it->second.lock(); + if (connection) + { + connection->notify(method, params); + } + } +} + +// static +LLSD LLScriptEditorWSServer::errorResponse(const std::string& message) +{ + LLSD response; + response["success"] = false; + response["message"] = message; + return response; +} + +LLSD LLScriptEditorWSServer::buildPrimInventoryLLSD(LLViewerObject* object) const +{ + 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(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(subtype); // 0=LSL, 1=Luau + + const std::string& runtime = item->getRuntime(); + if (!runtime.empty()) + { + entry["vm"] = runtime; + } + + // running state: omitted initially, backfilled async (Phase 4) + } + + // Permissions + const LLPermissions& perms = item->getPermissions(); + LLSD perm_entry; + perm_entry["owner"] = static_cast(perms.getMaskOwner()); + perm_entry["next_owner"] = static_cast(perms.getMaskNextOwner()); + entry["permissions"] = perm_entry; + + entry["creator_id"] = perms.getCreator(); + + items.append(entry); + } + + return items; +} + +bool LLScriptEditorWSServer::publishObject(const LLUUID& object_id, U32 connection_id) +{ + 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 prims; + prims.push_back(root); + for (LLViewerObject* child : root->getChildren()) + { + prims.push_back(child); + } + + // 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; + pending.mConnectionID = connection_id; + + for (LLViewerObject* prim : prims) + { + pending.mPendingPrims.insert(prim->getID()); + auto listener = std::make_unique( + 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) +{ + 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); + } +} + +void LLScriptEditorWSServer::buildAndSendPublish(const LLUUID& object_id) +{ + 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; + } + + U32 connection_id = pending_it->second.mConnectionID; + + LLViewerObject* root = gObjectList.findObject(object_id); + if (!root) + { + LL_WARNS("ScriptEditorWS") << "buildAndSendPublish: root object gone: " << object_id << LL_ENDL; + mPendingPublishes.erase(pending_it); + return; + } + + // Build the publish LLSD + // Object name and description come from ObjectPropertiesFamily (async), + // so look them up from the selection node if available; fall back to empty. + auto getNodeName = [](LLViewerObject* obj) -> std::string { + LLSelectNode* node = LLSelectMgr::instance().getSelection()->findNode(obj); + return (node && !node->mName.empty()) ? node->mName : std::string(); + }; + auto getNodeDesc = [](LLViewerObject* obj) -> std::string { + LLSelectNode* node = LLSelectMgr::instance().getSelection()->findNode(obj); + return (node && !node->mDescription.empty()) ? node->mDescription : std::string(); + }; + + LLSD pub; + pub["object_id"] = root->getID(); + pub["object_name"] = getNodeName(root); + pub["object_description"] = getNodeDesc(root); + 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"] = getNodeName(child); + link["link_description"] = getNodeDesc(child); + link["inventory"] = buildPrimInventoryLLSD(child); + linked_objects.append(link); + } + if (linked_objects.size() > 0) + { + pub["linked_objects"] = linked_objects; + } + + // Store in the published registry + PublishedObjectInfo info; + info.mObjectID = root->getID(); + info.mOwnerID = root->mOwnerID; + info.mObjectName = getNodeName(root); + info.mObjectDescription = getNodeDesc(root); + info.mConnectionID = connection_id; + if (root->getRegion()) + { + info.mRegionName = root->getRegion()->getName(); + } + + auto con_it = mActiveConnections.find(connection_id); + if (con_it != mActiveConnections.end()) + { + info.mConnection = con_it->second; + } + + S32 link_num = 1; + std::vector all_prims; + all_prims.push_back(root); + for (LLViewerObject* child : root->getChildren()) + { + all_prims.push_back(child); + } + for (LLViewerObject* prim : all_prims) + { + PublishedPrimInfo prim_info; + prim_info.mPrimID = prim->getID(); + prim_info.mPrimName = getNodeName(prim); + prim_info.mLinkNumber = link_num++; + prim_info.mInventorySerial = static_cast(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; + notifyConnection(connection_id, "object.publish", message); + + LL_INFOS("ScriptEditorWS") << "Published object " << object_id + << " (" << getNodeName(root) << ") with " + << (all_prims.size() - 1) << " linked prim(s)" << LL_ENDL; +} + +void LLScriptEditorWSServer::onPrimInventoryChanged(const LLUUID& object_id, const LLUUID& prim_id) +{ + auto pub_it = mPublishedObjects.find(object_id); + if (pub_it == mPublishedObjects.end()) + return; + + LLViewerObject* prim = gObjectList.findObject(prim_id); + if (!prim) + return; + + LLSD update; + update["object_id"] = object_id; + update["prim_id"] = prim_id; + update["inventory"] = buildPrimInventoryLLSD(prim); + + notifyConnection(pub_it->second.mConnectionID, "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::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) +{ + auto it = mPublishedObjects.find(object_id); + if (it == mPublishedObjects.end()) + { + // May still have a pending publish in progress -- cancel it + cleanupPrimListeners(object_id); + return; + } + + U32 connection_id = it->second.mConnectionID; + + cleanupPrimListeners(object_id); + mPublishedObjects.erase(it); + + LLSD message; + message["object_id"] = object_id; + if (!reason.empty()) + { + message["reason"] = reason; + } + notifyConnection(connection_id, "object.unpublish", message); + + LL_DEBUGS("ScriptEditorWS") << "Unpublished object " << object_id + << " reason: " << reason << LL_ENDL; +} + +void LLScriptEditorWSServer::unpublishConnection(U32 connection_id) +{ + // Collect object IDs first to avoid modifying the map while iterating + std::vector to_unpublish; + for (const auto& [id, info] : mPublishedObjects) + { + if (info.mConnectionID == connection_id) + { + to_unpublish.push_back(id); + } + } + + // Also cancel any pending publishes for this connection + for (const auto& [id, pending] : mPendingPublishes) + { + if (pending.mConnectionID == connection_id) + { + to_unpublish.push_back(id); + } + } + + for (const LLUUID& object_id : to_unpublish) + { + // No notification sent -- connection is already closing + cleanupPrimListeners(object_id); + mPublishedObjects.erase(object_id); + } +} + //======================================================================== U32 LLScriptEditorWSConnection::sNextConnectionID = 1; diff --git a/indra/newview/llscripteditorws.h b/indra/newview/llscripteditorws.h index 765ebe83f2a..528952fe5a8 100644 --- a/indra/newview/llscripteditorws.h +++ b/indra/newview/llscripteditorws.h @@ -43,6 +43,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 { @@ -162,6 +165,11 @@ class LLScriptEditorWSServer : public LLJSONRPCServer virtual ~LLScriptEditorWSServer() = 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 +190,14 @@ class LLScriptEditorWSServer : public LLJSONRPCServer std::set getActiveScripts() const; + // --- Object Content Publishing --- + bool publishObject(const LLUUID& object_id, U32 connection_id); + void unpublishObject(const LLUUID& object_id, const std::string& reason = ""); + bool isObjectPublished(const LLUUID& object_id) const; + void unpublishConnection(U32 connection_id); + void onPrimInventoryReady(const LLUUID& object_id, const LLUUID& prim_id); + void onPrimInventoryChanged(const LLUUID& object_id, const LLUUID& prim_id); + protected: LLWebsocketMgr::WSConnection::ptr_t connectionFactory(LLWebsocketMgr::WSServer::ptr_t server, LLWebsocketMgr::connection_h handle) override; @@ -197,6 +213,29 @@ class LLScriptEditorWSServer : public LLJSONRPCServer 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); + + struct ValidatedItem + { + LLViewerObject* prim; + LLViewerObject* root; + LLInventoryItem* item; + LLAssetType::EType type; + }; + ValidatedItem validatePublishedItem(const LLSD& params, U32 permMask) const; + + // --- Object Content Publishing (helpers) --- + LLSD buildPrimInventoryLLSD(LLViewerObject* object) const; + void notifyConnection(U32 connection_id, const std::string& method, const LLSD& params) const; + void cleanupPrimListeners(const LLUUID& object_id); + void buildAndSendPublish(const LLUUID& object_id); + static LLSD errorResponse(const std::string& message); private: struct EditorSubscription @@ -216,12 +255,45 @@ class LLScriptEditorWSServer : public LLJSONRPCServer }; using subscriptions_t = std::unordered_map; + 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; + U32 mConnectionID; // owning WS connection + LLScriptEditorWSConnection::wptr_t mConnection; + std::vector mPrims; // root + all children + std::vector> mListeners; + }; + + struct PendingPublish + { + LLUUID mObjectID; + U32 mConnectionID; + std::set mPendingPrims; // prims whose inventory we're still waiting for + std::vector> mListeners; // listeners for pending prims + }; + SubscriptionError_t updateScriptSubscription(const std::string &script_id, U32 connection_id); void unsubscribeConnection(U32 connection_id); subscriptions_t mSubscriptions; std::map mActiveConnections; + std::map mPublishedObjects; // keyed by root object_id + std::map mPendingPublishes; // keyed by root object_id + std::map mPendingItemCreates; // prim_id -> pump name awaiting inventory update + boost::signals2::connection mLanguageChangeSignal; LLUUID mLastSyntaxId; diff --git a/indra/newview/skins/default/xui/en/strings.xml b/indra/newview/skins/default/xui/en/strings.xml index 1860d38b0e4..c74449d9f95 100644 --- a/indra/newview/skins/default/xui/en/strings.xml +++ b/indra/newview/skins/default/xui/en/strings.xml @@ -4046,6 +4046,8 @@ Try enclosing path to the editor with double quotes. (e.g. "/path to my/editor" "%s") Error parsing the external editor command. External editor failed to run. + Failed to start the WebSocket server. Ensure the ExternalWebsocketSyncEnable setting is on. + Failed to launch VS Code. Ensure the 'code' command is available on your PATH. Translation failed: [REASON]