From a6a2f98070cd55fcaadafc237bd0ffba9d466655 Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Wed, 22 Jul 2026 17:41:55 -0700 Subject: 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. --- autobuild.xml | 8 +- doc/external-editor-json-rpc.md | 694 ++++++- indra/llcommon/llprofilercategories.h | 19 + indra/llcorehttp/lljsonrpcws.cpp | 389 +++- indra/llcorehttp/lljsonrpcws.h | 95 +- indra/llcorehttp/llwebsocketmgr.cpp | 36 + indra/llcorehttp/llwebsocketmgr.h | 5 + indra/llinventory/llinventorytype.h | 2 + indra/newview/app_settings/settings.xml | 11 + indra/newview/llfloaterimnearbychathandler.cpp | 5 +- indra/newview/llfloaterscripting.cpp | 28 +- indra/newview/llfloaterscripting.h | 4 + indra/newview/llinventorybridge.cpp | 9 + indra/newview/llinventorybridge.h | 1 + indra/newview/llinventoryicon.cpp | 14 +- indra/newview/llinventoryicon.h | 1 + indra/newview/llpanelcontents.cpp | 225 +- indra/newview/llpanelcontents.h | 13 +- indra/newview/llpanelobjectinventory.cpp | 12 + indra/newview/llpreviewscript.cpp | 105 +- indra/newview/llscripteditorws.cpp | 2170 ++++++++++++++++++-- indra/newview/llscripteditorws.h | 163 +- indra/newview/llselectmgr.cpp | 11 + indra/newview/llviewerinventory.cpp | 10 + indra/newview/llviewerinventory.h | 8 + indra/newview/llviewermenu.cpp | 46 + indra/newview/llviewerobject.cpp | 133 ++ indra/newview/llviewerobject.h | 12 + indra/newview/llviewerregion.cpp | 1 + .../default/textures/icons/Inv_Script_Luau.png | Bin 0 -> 807 bytes indra/newview/skins/default/textures/textures.xml | 1 + .../default/xui/en/floater_scripting_settings.xml | 15 +- .../newview/skins/default/xui/en/floater_tools.xml | 51 +- indra/newview/skins/default/xui/en/menu_viewer.xml | 21 +- indra/newview/skins/default/xui/en/strings.xml | 2 + 35 files changed, 3833 insertions(+), 487 deletions(-) create mode 100644 indra/newview/skins/default/textures/icons/Inv_Script_Luau.png diff --git a/autobuild.xml b/autobuild.xml index 66c86f6c06..08d0df7ec9 100644 --- a/autobuild.xml +++ b/autobuild.xml @@ -1333,11 +1333,11 @@ archive hash - 9e8a23b240897ca2e98f6700fae3bec46ad0d0f2 + c9928095cf40377e901458f5240305a7af2c554d hash_algorithm sha1 url - https://github.com/secondlife/lsl-definitions/releases/download/v0.6.3/lsl_definitions-0.6.3-common-25336758775.tar.zst + https://github.com/secondlife/lsl-definitions/releases/download/v0.6.12/lsl_definitions-0.6.12-common-29281156609.tar.zst name common @@ -1348,9 +1348,9 @@ license_file LICENSES/lsl_definitions.txt copyright - Copyright (c) 2026, Linden Lab + Copyright (c) 2026, Linden Research, Inc. version - 0.6.3 + 0.6.12 use_scm_version true name diff --git a/doc/external-editor-json-rpc.md b/doc/external-editor-json-rpc.md index 9d355957fe..a3404ce1eb 100644 --- a/doc/external-editor-json-rpc.md +++ b/doc/external-editor-json-rpc.md @@ -5,12 +5,14 @@ This document describes all the message interfaces defined for WebSocket communi ## Table of Contents - [Usage Flow](#usage-flow) +- [VS Code Launch URI](#vs-code-launch-uri) - [JSON-RPC Method Summary](#json-rpc-method-summary) - [Session Management Interfaces](#session-management-interfaces) - [SessionHandshake](#sessionhandshake) - [SessionHandshakeResponse](#sessionhandshakeresponse) - [Session OK](#session-ok) - [SessionDisconnect](#sessiondisconnect) + - [SessionPing](#sessionping) - [Language and Syntax Interfaces](#language-and-syntax-interfaces) - [SyntaxChange](#syntaxchange) - [Language Syntax ID Request](#language-syntax-id-request) @@ -31,6 +33,19 @@ This document describes all the message interfaces defined for WebSocket communi - [Handler and Configuration Interfaces](#handler-and-configuration-interfaces) - [WebSocketHandlers](#websockethandlers) - [ClientInfo](#clientinfo) +- [Object Content Interfaces](#object-content-interfaces) + - [Core Data Types](#core-data-types) + - [ObjectPublish](#objectpublish) + - [ObjectUnpublish](#objectunpublish) + - [ObjectUpdate](#objectupdate) + - [ObjectContentGet](#objectcontentget) + - [ObjectContentSave](#objectcontentsave) + - [ObjectItemCreate](#objectitemcreate) + - [ObjectItemDelete](#objectitemdelete) + - [ObjectScriptSetRunning](#objectscriptsetrunning) + - [ObjectRequest](#objectrequest) + - [ObjectModify](#objectmodify) + - [ObjectItemModify](#objectitemmodify) ## Usage Flow @@ -53,42 +68,134 @@ 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` | +| `session.ping` | Bidirectional | Call | `SessionPing` | +| `session.ping` (response) | Bidirectional | Response | `SessionPingResponse` | +| `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.unpublish` | Extension → Viewer | Call | `ObjectUnpublishParams` | +| `object.unpublish` (response) | Viewer → Extension | Response | `ObjectUnpublishResponse` || `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.script.reset` | Extension -> Viewer | Call | `ObjectScriptResetParams` | +| `object.script.reset` (response)| Viewer -> Extension | Response | `ObjectScriptResetResponse` | +| `object.request` | Extension -> Viewer | Call | `ObjectRequestParams` | +| `object.request` (response) | Viewer -> Extension | Response | `ObjectRequestResponse` | +| `object.list` | Extension -> Viewer | Call | `{}` (no params) | +| `object.list` (response) | Viewer -> Extension | Response | `ObjectListResponse` | +| `object.modify` | Extension -> Viewer | Call | `ObjectModifyParams` | +| `object.modify` (response) | Viewer -> Extension | Response | `ObjectModifyResponse` | +| `object.item.modify` | Extension -> Viewer | Call | `ObjectItemModifyParams` | +| `object.item.modify` (response) | Viewer -> Extension | Response | `ObjectItemModifyResponse` | ## Session Management Interfaces @@ -188,6 +295,62 @@ interface SessionDisconnect { - `4`: Internal server error - `message`: Human-readable description of the disconnect reason +### SessionPing + +**JSON-RPC Method:** `session.ping` (call, bidirectional) + +Heartbeat call used to verify the connection is alive and measure latency. Either side can initiate a ping; the recipient responds with the original timestamp plus its own server time. + +```typescript +interface SessionPing { + timestamp: number; +} +``` + +**Fields:** + +- `timestamp`: Unix timestamp in milliseconds when the ping was sent + +**Response:** + +```typescript +interface SessionPingResponse { + timestamp: number; + server_time: number; +} +``` + +**Response Fields:** + +- `timestamp`: The original timestamp from the request (echoed back) +- `server_time`: Unix timestamp in milliseconds when the response was generated + +**Example Request:** + +```json +{ + "jsonrpc": "2.0", + "method": "session.ping", + "id": 42, + "params": { + "timestamp": 1721145600000 + } +} +``` + +**Example Response:** + +```json +{ + "jsonrpc": "2.0", + "id": 42, + "result": { + "timestamp": 1721145600000, + "server_time": 1721145600015 + } +} +``` + ## Language and Syntax Interfaces ### SyntaxChange @@ -293,7 +456,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 +545,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 +685,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 +741,480 @@ 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 + faulted?: boolean; // Scripts only: whether the script has a runtime fault + 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"`) + +**JSON-RPC Method:** `object.unpublish` (call from extension to viewer) + +The extension may also call `object.unpublish` to manually stop tracking an object. The viewer will stop publishing it and send a corresponding `object.unpublish` notification back to the caller. + +```typescript +interface ObjectUnpublishParams { + object_id: string; // UUID of the root prim to unpublish +} + +interface ObjectUnpublishResponse { + success: boolean; + object_id?: string; +} +``` + +**Fields:** + +- `object_id`: UUID of the root prim to unpublish. +- `success`: `true` if the object was published and has been removed. + +**Note:** The viewer also sends an `object.unpublish` notification to the caller immediately after responding. Extensions should handle that notification idempotently. + +--- + +### 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; +} +``` + +--- + +### ObjectScriptReset + +**JSON-RPC Method:** `object.script.reset` (call from extension to viewer) + +Resets a script within a prim, clearing its state and restarting from the default state entry. + +```typescript +interface ObjectScriptResetParams { + prim_id: string; + item_id: string; +} + +interface ObjectScriptResetResponse { + 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) + +--- + +### ObjectList + +**JSON-RPC Method:** `object.list` (call from extension to viewer) + +Requests the complete list of currently published objects. Called by the extension immediately after the handshake completes (`session.ok`) to restore state for any objects the viewer already has published. + +The viewer responds synchronously with all published objects in the same format as `object.publish` notifications. No follow-up notifications are sent. + +```typescript +// No request parameters + +interface ObjectListResponse { + objects: PublishedObject[]; // All currently published objects; empty array if none +} +``` + +**Fields:** + +- `objects`: Array of `PublishedObject` records (same shape as the `object` field in `object.publish`). Empty array when no objects are currently published. + +**Sequence:** +1. Viewer sends `session.ok` +2. Extension calls `object.list` (no params) +3. Viewer responds with `{ objects: [...] }` synchronously + +--- + +### ObjectModify + +**JSON-RPC Method:** `object.modify` (call from extension to viewer) + +Modifies properties of a prim (root or linked) such as name, description, or permissions. Only specified fields are modified; omitted fields remain unchanged. Requires `PERM_MODIFY` on the object. + +```typescript +interface ObjectModifyParams { + prim_id: string; // UUID of any prim (root or child) + name?: string; // New display name + description?: string; // New description + permissions?: { + next_owner?: number; // Permission mask applied on transfer + }; +} + +interface ObjectModifyResponse { + success: boolean; + prim_id: string; // Echoed back from request + message?: string; // Error description on failure +} +``` + +**Fields:** + +- `prim_id`: UUID of the prim to modify. Child prims are addressable directly by UUID. +- `name` (optional): New display name for the prim. If omitted, name remains unchanged. +- `description` (optional): New description for the prim. If omitted, description remains unchanged. +- `permissions` (optional): Permission changes. + - `next_owner`: Permission mask applied when the object is transferred. Uses same bit flags as `ItemPermissions` (e.g., `PERM_MODIFY=0x4000`, `PERM_COPY=0x8000`, `PERM_TRANSFER=0x2000`). +- `success`: Whether the update operation succeeded. +- `message` (optional): Error description. Only present when `success` is `false`. + +**Notes:** +- At least one property field (`name`, `description`, or `permissions`) must be specified. +- An `object.update` notification will fire after successful modification. +- Owner permissions cannot be modified directly — only `next_owner` can be changed. + +--- + +### ObjectItemModify + +**JSON-RPC Method:** `object.item.modify` (call from extension to viewer) + +Modifies properties of an inventory item such as name, description, or permissions. Only specified fields are modified; omitted fields remain unchanged. Requires `PERM_MODIFY` on the item. + +```typescript +interface ObjectItemModifyParams { + prim_id: string; // UUID of any prim (root or child) + item_id: string; // Inventory item UUID + name?: string; // New display name (no file extension) + description?: string; // New description + permissions?: { + next_owner?: number; // Permission mask applied on transfer + }; +} + +interface ObjectItemModifyResponse { + success: boolean; + prim_id: string; // Echoed back from request + item_id: string; // Echoed back from request + message?: string; // Error description on failure +} +``` + +**Fields:** + +- `prim_id`: UUID of the prim that owns the item. Child prims are addressable directly by UUID. +- `item_id`: Inventory item UUID. +- `name` (optional): New display name for the item. Should not include file extension (e.g., `.lsl`, `.luau`). If omitted, name remains unchanged. +- `description` (optional): New description for the item. If omitted, description remains unchanged. +- `permissions` (optional): Permission changes. + - `next_owner`: Permission mask applied when the item is transferred. Uses same bit flags as `ItemPermissions` (e.g., `PERM_MODIFY=0x4000`, `PERM_COPY=0x8000`, `PERM_TRANSFER=0x2000`). +- `success`: Whether the update operation succeeded. +- `message` (optional): Error description. Only present when `success` is `false`. + +**Notes:** +- At least one property field (`name`, `description`, or `permissions`) must be specified. +- 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. 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 @@ -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 pending; + { + LLMutexLock lock(&mMutex); + pending.swap(mPendingRequests); + // Deadlines correspond to entries in mPendingRequests; drop them. + std::priority_queue 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(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> 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 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 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()) { - request["params"] = params; + env["params"] = params; } + if (result.isDefined()) + { + 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( + 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 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 #include #include +#include + +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,9 +243,31 @@ 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 mMethodHandlers; + std::unordered_map mAsyncMethodHandlers; std::unordered_map 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 mPendingDeadlines; + std::weak_ptr 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; using MethodHandler = LLJSONRPCConnection::MethodHandler; using ResponseCallback = LLJSONRPCConnection::ResponseCallback; - using BatchResponseCallback = std::function; 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; @@ -426,15 +480,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 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 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 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 >; 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 @@ 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/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("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("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("button publish"); + mPublishButton->setClickedCallback([this](LLUICtrl*, const LLSD&) { onClickPublish(); }); + mFilterEditor = getChild("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("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,38 +292,131 @@ 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 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); - - std::string name = new_item->getName(); - - // *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. + object->saveScript(new_item, true, true, LLUUID::null); + } + else + { + LL_WARNS() << "Cannot create " << LLAssetType::lookup(asset_type) + << " — capability not available" << LL_ENDL; } } +void LLPanelContents::onNewNotecardCommit() +{ + const bool children_ok = true; + LLViewerObject* object = LLSelectMgr::getInstance()->getSelection()->getFirstRootObject(children_ok); + if (!object) return; + + createTaskInventoryItemHelper(object, + LLAssetType::AT_NOTECARD, + LLInventoryType::IT_NOTECARD, + 0, + "New Notecard", + LLSD()); +} + // static 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( - 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 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 ".", 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 + 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)(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": }. + 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 collect_linkset(LLViewerObject* root) + { + std::vector 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 LLScriptEditorWSServer::sEnableScriptEditorWS(gSavedSettings, "ExternalWebsocketSyncEnable", false); +LLCachedControl 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( + 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 +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& 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)); } - return false; + else + { + // Refresh existing subscription with the new editor handle + it->second.mEditorHandle = editor_handle; + } + 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,76 +551,411 @@ 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(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(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(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(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 new_item = + new LLViewerInventoryItem(static_cast(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(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() @@ -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(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(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,14 +1160,611 @@ LLSD LLScriptEditorWSServer::handleFileWatcherFileListRequest() const return response; } -void LLScriptEditorWSServer::notifyScript(const std::string& script_id, const std::string &method, const LLSD& message) const +LLSD LLScriptEditorWSServer::handleObjectRequest(U32 connection_id, const LLSD& params) { - auto it = mSubscriptions.find(script_id); - if (it != mSubscriptions.end()) + LLUUID object_id = params["object_id"].asUUID(); + LLSD response; + + if (object_id.isNull()) { - auto connection = it->second.mConnection.lock(); - if (connection) - { + 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(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(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(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(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()); + } + + 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( + 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("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( + 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("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::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 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 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 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(sub_type); + } + + LLSD perm_entry; + perm_entry["owner"] = static_cast(perms.getMaskOwner()); + perm_entry["next_owner"] = static_cast(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(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(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(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("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()) + { + auto connection = it->second.mConnection.lock(); + if (connection) + { connection->notify(method, message); } } @@ -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 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 lines = LLStringUtil::getTokens(chat_msg.mText, "\n"); // If this is a runtime error, the first line will look like: " [script: