diff --git a/CLAUDE.md b/CLAUDE.md index 6266297..cff74eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,15 @@ - Docker-backed Rust and TypeScript tests build `docker/test-agent/Dockerfile` directly in-process and cache the image tag only in memory (`OnceLock` in Rust, module-level variable in TypeScript). - Do not add cross-process image-build scripts unless there is a concrete need for them. +## Common Software Sync + +- These three files must stay in sync: + - `docs/common-software.mdx` (user-facing documentation) + - `docker/test-common-software/Dockerfile` (packages installed in the test image) + - `server/packages/sandbox-agent/tests/common_software.rs` (test assertions) +- When adding or removing software from `docs/common-software.mdx`, also add/remove the corresponding `apt-get install` line in the Dockerfile and add/remove the test in `common_software.rs`. +- Run `cargo test -p sandbox-agent --test common_software` to verify. + ## Install Version References - Channel policy: diff --git a/docker/test-common-software/Dockerfile b/docker/test-common-software/Dockerfile new file mode 100644 index 0000000..7a03abc --- /dev/null +++ b/docker/test-common-software/Dockerfile @@ -0,0 +1,37 @@ +# Extends the base test-agent image with common software pre-installed. +# Used by the common_software integration test to verify that all documented +# software in docs/common-software.mdx works correctly inside the sandbox. +# +# KEEP IN SYNC with docs/common-software.mdx + +ARG BASE_IMAGE=sandbox-agent-test:dev +FROM ${BASE_IMAGE} + +USER root + +RUN apt-get update -qq && \ + apt-get install -y -qq --no-install-recommends \ + # Browsers + chromium \ + firefox-esr \ + # Languages + python3 python3-pip python3-venv \ + default-jdk \ + ruby-full \ + # Databases + sqlite3 \ + redis-server \ + # Build tools + build-essential cmake pkg-config \ + # CLI tools + git jq tmux \ + # Media and graphics + imagemagick \ + poppler-utils \ + # Desktop apps + gimp \ + > /dev/null 2>&1 && \ + rm -rf /var/lib/apt/lists/* + +ENTRYPOINT ["/usr/local/bin/sandbox-agent"] +CMD ["server", "--host", "0.0.0.0", "--port", "3000", "--no-token"] diff --git a/docs/computer-use.mdx b/docs/computer-use.mdx index 63157cd..fc6b7d0 100644 --- a/docs/computer-use.mdx +++ b/docs/computer-use.mdx @@ -41,6 +41,49 @@ curl -X POST "http://127.0.0.1:2468/v1/desktop/stop" All fields in the start request are optional. Defaults are 1440x900 at 96 DPI. +### Start request options + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `width` | number | 1440 | Desktop width in pixels | +| `height` | number | 900 | Desktop height in pixels | +| `dpi` | number | 96 | Display DPI | +| `displayNum` | number | 99 | Starting X display number. The runtime probes from this number upward to find an available display. | +| `stateDir` | string | (auto) | Desktop state directory for home, logs, recordings | +| `streamVideoCodec` | string | `"vp8"` | WebRTC video codec (`vp8`, `vp9`, `h264`) | +| `streamAudioCodec` | string | `"opus"` | WebRTC audio codec (`opus`, `g722`) | +| `streamFrameRate` | number | 30 | Streaming frame rate (1-60) | +| `webrtcPortRange` | string | `"59050-59070"` | UDP port range for WebRTC media | +| `recordingFps` | number | 30 | Default recording FPS when not specified in `startDesktopRecording` (1-60) | + +The streaming and recording options configure defaults for the desktop session. They take effect when streaming or recording is started later. + + +```ts TypeScript +const status = await sdk.startDesktop({ + width: 1920, + height: 1080, + streamVideoCodec: "h264", + streamFrameRate: 60, + webrtcPortRange: "59100-59120", + recordingFps: 15, +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/desktop/start" \ + -H "Content-Type: application/json" \ + -d '{ + "width": 1920, + "height": 1080, + "streamVideoCodec": "h264", + "streamFrameRate": 60, + "webrtcPortRange": "59100-59120", + "recordingFps": 15 + }' +``` + + ## Status @@ -56,7 +99,7 @@ curl "http://127.0.0.1:2468/v1/desktop/status" ## Screenshots -Capture the full desktop or a specific region. +Capture the full desktop or a specific region. Optionally include the cursor position. ```ts TypeScript @@ -70,6 +113,11 @@ const jpeg = await sdk.takeDesktopScreenshot({ scale: 0.5, }); +// Include cursor overlay +const withCursor = await sdk.takeDesktopScreenshot({ + showCursor: true, +}); + // Region screenshot const region = await sdk.takeDesktopRegionScreenshot({ x: 100, @@ -85,11 +133,26 @@ curl "http://127.0.0.1:2468/v1/desktop/screenshot" --output screenshot.png curl "http://127.0.0.1:2468/v1/desktop/screenshot?format=jpeg&quality=70&scale=0.5" \ --output screenshot.jpg +# Include cursor overlay +curl "http://127.0.0.1:2468/v1/desktop/screenshot?show_cursor=true" \ + --output with_cursor.png + curl "http://127.0.0.1:2468/v1/desktop/screenshot/region?x=100&y=100&width=400&height=300" \ --output region.png ``` +### Screenshot options + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `format` | string | `"png"` | Output format: `png`, `jpeg`, or `webp` | +| `quality` | number | 85 | Compression quality (1-100, JPEG/WebP only) | +| `scale` | number | 1.0 | Scale factor (0.1-1.0) | +| `showCursor` | boolean | `false` | Composite a crosshair at the cursor position | + +When `showCursor` is enabled, the cursor position is captured at the moment of the screenshot and a red crosshair is drawn at that location. This is useful for AI agents that need to see where the cursor is in the screenshot. + ## Mouse @@ -166,6 +229,52 @@ curl -X POST "http://127.0.0.1:2468/v1/desktop/keyboard/press" \ ``` +## Clipboard + +Read and write the X11 clipboard programmatically. + + +```ts TypeScript +// Read clipboard +const clipboard = await sdk.getDesktopClipboard(); +console.log(clipboard.text); + +// Read primary selection (mouse-selected text) +const primary = await sdk.getDesktopClipboard({ selection: "primary" }); + +// Write to clipboard +await sdk.setDesktopClipboard({ text: "Pasted via API" }); + +// Write to both clipboard and primary selection +await sdk.setDesktopClipboard({ + text: "Synced text", + selection: "both", +}); +``` + +```bash cURL +curl "http://127.0.0.1:2468/v1/desktop/clipboard" + +curl "http://127.0.0.1:2468/v1/desktop/clipboard?selection=primary" + +curl -X POST "http://127.0.0.1:2468/v1/desktop/clipboard" \ + -H "Content-Type: application/json" \ + -d '{"text":"Pasted via API"}' + +curl -X POST "http://127.0.0.1:2468/v1/desktop/clipboard" \ + -H "Content-Type: application/json" \ + -d '{"text":"Synced text","selection":"both"}' +``` + + +The `selection` parameter controls which X11 selection to read or write: + +| Value | Description | +|-------|-------------| +| `clipboard` (default) | The standard clipboard (Ctrl+C / Ctrl+V) | +| `primary` | The primary selection (text selected with the mouse) | +| `both` | Write to both clipboard and primary selection (write only) | + ## Display and windows @@ -186,6 +295,112 @@ curl "http://127.0.0.1:2468/v1/desktop/windows" ``` +The windows endpoint filters out noise automatically: window manager internals (Openbox), windows with empty titles, and tiny helper windows (under 120x80) are excluded. The currently active/focused window is always included regardless of filters. + +### Focused window + +Get the currently focused window without listing all windows. + + +```ts TypeScript +const focused = await sdk.getDesktopFocusedWindow(); +console.log(focused.title, focused.id); +``` + +```bash cURL +curl "http://127.0.0.1:2468/v1/desktop/windows/focused" +``` + + +Returns 404 if no window currently has focus. + +### Window management + +Focus, move, and resize windows by their X11 window ID. + + +```ts TypeScript +const { windows } = await sdk.listDesktopWindows(); +const win = windows[0]; + +// Bring window to foreground +await sdk.focusDesktopWindow(win.id); + +// Move window +await sdk.moveDesktopWindow(win.id, { x: 100, y: 50 }); + +// Resize window +await sdk.resizeDesktopWindow(win.id, { width: 1280, height: 720 }); +``` + +```bash cURL +# Focus a window +curl -X POST "http://127.0.0.1:2468/v1/desktop/windows/12345/focus" + +# Move a window +curl -X POST "http://127.0.0.1:2468/v1/desktop/windows/12345/move" \ + -H "Content-Type: application/json" \ + -d '{"x":100,"y":50}' + +# Resize a window +curl -X POST "http://127.0.0.1:2468/v1/desktop/windows/12345/resize" \ + -H "Content-Type: application/json" \ + -d '{"width":1280,"height":720}' +``` + + +All three endpoints return the updated window info so you can verify the operation took effect. The window manager may adjust the requested position or size. + +## App launching + +Launch applications or open files/URLs on the desktop without needing to shell out. + + +```ts TypeScript +// Launch an app by name +const result = await sdk.launchDesktopApp({ + app: "firefox", + args: ["--private"], +}); +console.log(result.processId); // "proc_7" + +// Launch and wait for the window to appear +const withWindow = await sdk.launchDesktopApp({ + app: "xterm", + wait: true, +}); +console.log(withWindow.windowId); // "12345" or null if timed out + +// Open a URL with the default handler +const opened = await sdk.openDesktopTarget({ + target: "https://example.com", +}); +console.log(opened.processId); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/desktop/launch" \ + -H "Content-Type: application/json" \ + -d '{"app":"firefox","args":["--private"]}' + +curl -X POST "http://127.0.0.1:2468/v1/desktop/launch" \ + -H "Content-Type: application/json" \ + -d '{"app":"xterm","wait":true}' + +curl -X POST "http://127.0.0.1:2468/v1/desktop/open" \ + -H "Content-Type: application/json" \ + -d '{"target":"https://example.com"}' +``` + + +The returned `processId` can be used with the [Process API](/processes) to read logs (`GET /v1/processes/{id}/logs`) or stop the application (`POST /v1/processes/{id}/stop`). + +When `wait` is `true`, the API polls for up to 5 seconds for a window to appear. If the window appears, its ID is returned in `windowId`. If it times out, `windowId` is `null` but the process is still running. + + +**Launch/Open vs the Process API:** Both `launch` and `open` are convenience wrappers around the [Process API](/processes). They create managed processes (with `owner: "desktop"`) that you can inspect, log, and stop through the same Process endpoints. The difference is that `launch` validates the binary exists in PATH first and can optionally wait for a window to appear, while `open` delegates to the system default handler (`xdg-open`). Use the Process API directly when you need full control over command, environment, working directory, or restart policies. + + ## Recording Record the desktop to MP4. @@ -285,6 +500,11 @@ Start a WebRTC stream for real-time desktop viewing in a browser. ```ts TypeScript await sdk.startDesktopStream(); +// Check stream status +const status = await sdk.getDesktopStreamStatus(); +console.log(status.active); // true +console.log(status.processId); // "proc_5" + // Connect via the React DesktopViewer component or // use the WebSocket signaling endpoint directly // at ws://127.0.0.1:2468/v1/desktop/stream/signaling @@ -295,6 +515,9 @@ await sdk.stopDesktopStream(); ```bash cURL curl -X POST "http://127.0.0.1:2468/v1/desktop/stream/start" +# Check stream status +curl "http://127.0.0.1:2468/v1/desktop/stream/status" + # Connect to ws://127.0.0.1:2468/v1/desktop/stream/signaling for WebRTC signaling curl -X POST "http://127.0.0.1:2468/v1/desktop/stream/stop" @@ -303,6 +526,89 @@ curl -X POST "http://127.0.0.1:2468/v1/desktop/stream/stop" For a drop-in React component, see [React Components](/react-components). +## API reference + +### Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/v1/desktop/start` | Start the desktop runtime | +| `POST` | `/v1/desktop/stop` | Stop the desktop runtime | +| `GET` | `/v1/desktop/status` | Get desktop runtime status | +| `GET` | `/v1/desktop/screenshot` | Capture full desktop screenshot | +| `GET` | `/v1/desktop/screenshot/region` | Capture a region screenshot | +| `GET` | `/v1/desktop/mouse/position` | Get current mouse position | +| `POST` | `/v1/desktop/mouse/move` | Move the mouse | +| `POST` | `/v1/desktop/mouse/click` | Click the mouse | +| `POST` | `/v1/desktop/mouse/down` | Press mouse button down | +| `POST` | `/v1/desktop/mouse/up` | Release mouse button | +| `POST` | `/v1/desktop/mouse/drag` | Drag from one point to another | +| `POST` | `/v1/desktop/mouse/scroll` | Scroll at a position | +| `POST` | `/v1/desktop/keyboard/type` | Type text | +| `POST` | `/v1/desktop/keyboard/press` | Press a key with optional modifiers | +| `POST` | `/v1/desktop/keyboard/down` | Press a key down (hold) | +| `POST` | `/v1/desktop/keyboard/up` | Release a key | +| `GET` | `/v1/desktop/display/info` | Get display info | +| `GET` | `/v1/desktop/windows` | List visible windows | +| `GET` | `/v1/desktop/windows/focused` | Get focused window info | +| `POST` | `/v1/desktop/windows/{id}/focus` | Focus a window | +| `POST` | `/v1/desktop/windows/{id}/move` | Move a window | +| `POST` | `/v1/desktop/windows/{id}/resize` | Resize a window | +| `GET` | `/v1/desktop/clipboard` | Read clipboard contents | +| `POST` | `/v1/desktop/clipboard` | Write to clipboard | +| `POST` | `/v1/desktop/launch` | Launch an application | +| `POST` | `/v1/desktop/open` | Open a file or URL | +| `POST` | `/v1/desktop/recording/start` | Start recording | +| `POST` | `/v1/desktop/recording/stop` | Stop recording | +| `GET` | `/v1/desktop/recordings` | List recordings | +| `GET` | `/v1/desktop/recordings/{id}` | Get recording metadata | +| `GET` | `/v1/desktop/recordings/{id}/download` | Download recording | +| `DELETE` | `/v1/desktop/recordings/{id}` | Delete recording | +| `POST` | `/v1/desktop/stream/start` | Start WebRTC streaming | +| `POST` | `/v1/desktop/stream/stop` | Stop WebRTC streaming | +| `GET` | `/v1/desktop/stream/status` | Get stream status | +| `GET` | `/v1/desktop/stream/signaling` | WebSocket for WebRTC signaling | + +### TypeScript SDK methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `startDesktop(request?)` | `DesktopStatusResponse` | Start the desktop | +| `stopDesktop()` | `DesktopStatusResponse` | Stop the desktop | +| `getDesktopStatus()` | `DesktopStatusResponse` | Get desktop status | +| `takeDesktopScreenshot(query?)` | `Uint8Array` | Capture screenshot | +| `takeDesktopRegionScreenshot(query)` | `Uint8Array` | Capture region screenshot | +| `getDesktopMousePosition()` | `DesktopMousePositionResponse` | Get mouse position | +| `moveDesktopMouse(request)` | `DesktopMousePositionResponse` | Move mouse | +| `clickDesktop(request)` | `DesktopMousePositionResponse` | Click mouse | +| `mouseDownDesktop(request)` | `DesktopMousePositionResponse` | Mouse button down | +| `mouseUpDesktop(request)` | `DesktopMousePositionResponse` | Mouse button up | +| `dragDesktopMouse(request)` | `DesktopMousePositionResponse` | Drag mouse | +| `scrollDesktop(request)` | `DesktopMousePositionResponse` | Scroll | +| `typeDesktopText(request)` | `DesktopActionResponse` | Type text | +| `pressDesktopKey(request)` | `DesktopActionResponse` | Press key | +| `keyDownDesktop(request)` | `DesktopActionResponse` | Key down | +| `keyUpDesktop(request)` | `DesktopActionResponse` | Key up | +| `getDesktopDisplayInfo()` | `DesktopDisplayInfoResponse` | Get display info | +| `listDesktopWindows()` | `DesktopWindowListResponse` | List windows | +| `getDesktopFocusedWindow()` | `DesktopWindowInfo` | Get focused window | +| `focusDesktopWindow(id)` | `DesktopWindowInfo` | Focus a window | +| `moveDesktopWindow(id, request)` | `DesktopWindowInfo` | Move a window | +| `resizeDesktopWindow(id, request)` | `DesktopWindowInfo` | Resize a window | +| `getDesktopClipboard(query?)` | `DesktopClipboardResponse` | Read clipboard | +| `setDesktopClipboard(request)` | `DesktopActionResponse` | Write clipboard | +| `launchDesktopApp(request)` | `DesktopLaunchResponse` | Launch an app | +| `openDesktopTarget(request)` | `DesktopOpenResponse` | Open file/URL | +| `startDesktopRecording(request?)` | `DesktopRecordingInfo` | Start recording | +| `stopDesktopRecording()` | `DesktopRecordingInfo` | Stop recording | +| `listDesktopRecordings()` | `DesktopRecordingListResponse` | List recordings | +| `getDesktopRecording(id)` | `DesktopRecordingInfo` | Get recording | +| `downloadDesktopRecording(id)` | `Uint8Array` | Download recording | +| `deleteDesktopRecording(id)` | `void` | Delete recording | +| `startDesktopStream()` | `DesktopStreamStatusResponse` | Start streaming | +| `stopDesktopStream()` | `DesktopStreamStatusResponse` | Stop streaming | +| `getDesktopStreamStatus()` | `DesktopStreamStatusResponse` | Stream status | + ## Customizing the desktop environment The desktop runs inside the sandbox filesystem, so you can customize it using the [File System](/file-system) API before or after starting the desktop. The desktop HOME directory is located at `~/.local/state/sandbox-agent/desktop/home` (or `$XDG_STATE_HOME/sandbox-agent/desktop/home` if `XDG_STATE_HOME` is set). diff --git a/docs/openapi.json b/docs/openapi.json index e16d526..b749aa4 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -628,6 +628,105 @@ } } }, + "/v1/desktop/clipboard": { + "get": { + "tags": ["v1"], + "summary": "Read the desktop clipboard.", + "description": "Returns the current text content of the X11 clipboard.", + "operationId": "get_v1_desktop_clipboard", + "parameters": [ + { + "name": "selection", + "in": "query", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Clipboard contents", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopClipboardResponse" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Clipboard read failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "post": { + "tags": ["v1"], + "summary": "Write to the desktop clipboard.", + "description": "Sets the text content of the X11 clipboard.", + "operationId": "post_v1_desktop_clipboard", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopClipboardWriteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Clipboard updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopActionResponse" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Clipboard write failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/v1/desktop/display/info": { "get": { "tags": ["v1"], @@ -908,6 +1007,56 @@ } } }, + "/v1/desktop/launch": { + "post": { + "tags": ["v1"], + "summary": "Launch a desktop application.", + "description": "Launches an application by name on the managed desktop, optionally waiting\nfor its window to appear.", + "operationId": "post_v1_desktop_launch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopLaunchRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Application launched", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopLaunchResponse" + } + } + } + }, + "404": { + "description": "Application not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/v1/desktop/mouse/click": { "post": { "tags": ["v1"], @@ -1308,6 +1457,46 @@ } } }, + "/v1/desktop/open": { + "post": { + "tags": ["v1"], + "summary": "Open a file or URL with the default handler.", + "description": "Opens a file path or URL using xdg-open on the managed desktop.", + "operationId": "post_v1_desktop_open", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopOpenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Target opened", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopOpenResponse" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/v1/desktop/recording/start": { "post": { "tags": ["v1"], @@ -1585,6 +1774,15 @@ "format": "float", "nullable": true } + }, + { + "name": "showCursor", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } } ], "responses": { @@ -1702,6 +1900,15 @@ "format": "float", "nullable": true } + }, + { + "name": "showCursor", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } } ], "responses": { @@ -1871,51 +2078,11 @@ } } }, - "/v1/desktop/stream/start": { - "post": { - "tags": ["v1"], - "summary": "Start desktop streaming.", - "description": "Enables desktop websocket streaming for the managed desktop.", - "operationId": "post_v1_desktop_stream_start", - "responses": { - "200": { - "description": "Desktop streaming started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DesktopStreamStatusResponse" - } - } - } - } - } - } - }, - "/v1/desktop/stream/stop": { - "post": { - "tags": ["v1"], - "summary": "Stop desktop streaming.", - "description": "Disables desktop websocket streaming for the managed desktop.", - "operationId": "post_v1_desktop_stream_stop", - "responses": { - "200": { - "description": "Desktop streaming stopped", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DesktopStreamStatusResponse" - } - } - } - } - } - } - }, - "/v1/desktop/stream/ws": { + "/v1/desktop/stream/signaling": { "get": { "tags": ["v1"], - "summary": "Open a desktop websocket streaming session.", - "description": "Upgrades the connection to a websocket that streams JPEG desktop frames and\naccepts mouse and keyboard control frames.", + "summary": "Open a desktop WebRTC signaling session.", + "description": "Upgrades the connection to a WebSocket used for WebRTC signaling between\nthe browser client and the desktop streaming process. Also accepts mouse\nand keyboard input frames as a fallback transport.", "operationId": "get_v1_desktop_stream_ws", "parameters": [ { @@ -1956,6 +2123,66 @@ } } }, + "/v1/desktop/stream/start": { + "post": { + "tags": ["v1"], + "summary": "Start desktop streaming.", + "description": "Enables desktop websocket streaming for the managed desktop.", + "operationId": "post_v1_desktop_stream_start", + "responses": { + "200": { + "description": "Desktop streaming started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopStreamStatusResponse" + } + } + } + } + } + } + }, + "/v1/desktop/stream/status": { + "get": { + "tags": ["v1"], + "summary": "Get desktop stream status.", + "description": "Returns the current state of the desktop WebRTC streaming session.", + "operationId": "get_v1_desktop_stream_status", + "responses": { + "200": { + "description": "Desktop stream status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopStreamStatusResponse" + } + } + } + } + } + } + }, + "/v1/desktop/stream/stop": { + "post": { + "tags": ["v1"], + "summary": "Stop desktop streaming.", + "description": "Disables desktop websocket streaming for the managed desktop.", + "operationId": "post_v1_desktop_stream_stop", + "responses": { + "200": { + "description": "Desktop streaming stopped", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopStreamStatusResponse" + } + } + } + } + } + } + }, "/v1/desktop/windows": { "get": { "tags": ["v1"], @@ -1996,6 +2223,219 @@ } } }, + "/v1/desktop/windows/focused": { + "get": { + "tags": ["v1"], + "summary": "Get the currently focused desktop window.", + "description": "Returns information about the window that currently has input focus.", + "operationId": "get_v1_desktop_windows_focused", + "responses": { + "200": { + "description": "Focused window info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopWindowInfo" + } + } + } + }, + "404": { + "description": "No window is focused", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/windows/{id}/focus": { + "post": { + "tags": ["v1"], + "summary": "Focus a desktop window.", + "description": "Brings the specified window to the foreground and gives it input focus.", + "operationId": "post_v1_desktop_window_focus", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "X11 window ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Window info after focus", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopWindowInfo" + } + } + } + }, + "404": { + "description": "Window not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/windows/{id}/move": { + "post": { + "tags": ["v1"], + "summary": "Move a desktop window.", + "description": "Moves the specified window to the given position.", + "operationId": "post_v1_desktop_window_move", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "X11 window ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopWindowMoveRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Window info after move", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopWindowInfo" + } + } + } + }, + "404": { + "description": "Window not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/desktop/windows/{id}/resize": { + "post": { + "tags": ["v1"], + "summary": "Resize a desktop window.", + "description": "Resizes the specified window to the given dimensions.", + "operationId": "post_v1_desktop_window_resize", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "X11 window ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopWindowResizeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Window info after resize", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopWindowInfo" + } + } + } + }, + "404": { + "description": "Window not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Desktop runtime is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/v1/fs/entries": { "get": { "tags": ["v1"], @@ -3326,6 +3766,40 @@ } } }, + "DesktopClipboardQuery": { + "type": "object", + "properties": { + "selection": { + "type": "string", + "nullable": true + } + } + }, + "DesktopClipboardResponse": { + "type": "object", + "required": ["text", "selection"], + "properties": { + "selection": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "DesktopClipboardWriteRequest": { + "type": "object", + "required": ["text"], + "properties": { + "selection": { + "type": "string", + "nullable": true + }, + "text": { + "type": "string" + } + } + }, "DesktopDisplayInfoResponse": { "type": "object", "required": ["display", "resolution"], @@ -3421,6 +3895,45 @@ } } }, + "DesktopLaunchRequest": { + "type": "object", + "required": ["app"], + "properties": { + "app": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "wait": { + "type": "boolean", + "nullable": true + } + } + }, + "DesktopLaunchResponse": { + "type": "object", + "required": ["processId"], + "properties": { + "pid": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "processId": { + "type": "string" + }, + "windowId": { + "type": "string", + "nullable": true + } + } + }, "DesktopMouseButton": { "type": "string", "enum": ["left", "middle", "right"] @@ -3590,6 +4103,30 @@ } } }, + "DesktopOpenRequest": { + "type": "object", + "required": ["target"], + "properties": { + "target": { + "type": "string" + } + } + }, + "DesktopOpenResponse": { + "type": "object", + "required": ["processId"], + "properties": { + "pid": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "processId": { + "type": "string" + } + } + }, "DesktopProcessInfo": { "type": "object", "required": ["name", "running"], @@ -3698,6 +4235,10 @@ "format": "float", "nullable": true }, + "showCursor": { + "type": "boolean", + "nullable": true + }, "width": { "type": "integer", "format": "int32", @@ -3760,12 +4301,21 @@ "type": "number", "format": "float", "nullable": true + }, + "showCursor": { + "type": "boolean", + "nullable": true } } }, "DesktopStartRequest": { "type": "object", "properties": { + "displayNum": { + "type": "integer", + "format": "int32", + "nullable": true + }, "dpi": { "type": "integer", "format": "int32", @@ -3778,6 +4328,34 @@ "nullable": true, "minimum": 0 }, + "recordingFps": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "stateDir": { + "type": "string", + "nullable": true + }, + "streamAudioCodec": { + "type": "string", + "nullable": true + }, + "streamFrameRate": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "streamVideoCodec": { + "type": "string", + "nullable": true + }, + "webrtcPortRange": { + "type": "string", + "nullable": true + }, "width": { "type": "integer", "format": "int32", @@ -3840,6 +4418,13 @@ }, "state": { "$ref": "#/components/schemas/DesktopState" + }, + "windows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DesktopWindowInfo" + }, + "description": "Current visible windows (included when the desktop is active)." } } }, @@ -3849,6 +4434,14 @@ "properties": { "active": { "type": "boolean" + }, + "processId": { + "type": "string", + "nullable": true + }, + "windowId": { + "type": "string", + "nullable": true } } }, @@ -3897,6 +4490,36 @@ } } }, + "DesktopWindowMoveRequest": { + "type": "object", + "required": ["x", "y"], + "properties": { + "x": { + "type": "integer", + "format": "int32" + }, + "y": { + "type": "integer", + "format": "int32" + } + } + }, + "DesktopWindowResizeRequest": { + "type": "object", + "required": ["width", "height"], + "properties": { + "height": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "width": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, "ErrorType": { "type": "string", "enum": [ diff --git a/frontend/packages/inspector/index.html b/frontend/packages/inspector/index.html index c5c1ab7..6a5d064 100644 --- a/frontend/packages/inspector/index.html +++ b/frontend/packages/inspector/index.html @@ -2908,6 +2908,31 @@ gap: 10px; } + .desktop-screenshot-controls { + display: flex; + align-items: flex-end; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 12px; + } + + .desktop-checkbox-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + cursor: pointer; + white-space: nowrap; + padding-bottom: 4px; + } + + .desktop-advanced-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-top: 8px; + } + .desktop-input-group { display: flex; flex-direction: column; @@ -2950,6 +2975,68 @@ gap: 4px; } + .desktop-clipboard-text { + margin: 4px 0 0; + padding: 8px 10px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--surface); + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; + max-height: 120px; + overflow-y: auto; + } + + .desktop-window-item { + padding: 10px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--surface); + display: flex; + flex-direction: column; + gap: 6px; + } + + .desktop-window-focused { + border-color: var(--success); + box-shadow: inset 0 0 0 1px var(--success); + } + + .desktop-window-editor { + display: flex; + align-items: center; + gap: 6px; + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--border); + } + + .desktop-launch-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 6px; + flex-wrap: wrap; + } + + .desktop-mouse-pos { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + } + + .desktop-stream-hint { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + font-size: 11px; + color: var(--muted); + } + .desktop-screenshot-empty { padding: 18px; border: 1px dashed var(--border); @@ -3640,7 +3727,8 @@ } .desktop-state-grid, - .desktop-start-controls { + .desktop-start-controls, + .desktop-advanced-grid { grid-template-columns: 1fr; } diff --git a/frontend/packages/inspector/src/components/debug/DesktopTab.tsx b/frontend/packages/inspector/src/components/debug/DesktopTab.tsx index 1d95d3e..5f8b6e6 100644 --- a/frontend/packages/inspector/src/components/debug/DesktopTab.tsx +++ b/frontend/packages/inspector/src/components/debug/DesktopTab.tsx @@ -1,10 +1,35 @@ -import { Camera, Circle, Download, Loader2, Monitor, Play, RefreshCw, Square, Trash2, Video } from "lucide-react"; +import { + AppWindow, + Camera, + Circle, + Clipboard, + Download, + ExternalLink, + Loader2, + Monitor, + MousePointer, + Play, + RefreshCw, + Square, + Trash2, + Video, +} from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { SandboxAgentError } from "sandbox-agent"; -import type { DesktopRecordingInfo, DesktopStatusResponse, SandboxAgent } from "sandbox-agent"; +import type { DesktopRecordingInfo, DesktopStatusResponse, DesktopWindowInfo, SandboxAgent } from "sandbox-agent"; import { DesktopViewer } from "@sandbox-agent/react"; import type { DesktopViewerClient } from "@sandbox-agent/react"; const MIN_SPIN_MS = 350; +type DesktopScreenshotRequest = Parameters[0] & { + showCursor?: boolean; +}; +type DesktopStartRequestWithAdvanced = Parameters[0] & { + streamVideoCodec?: string; + streamAudioCodec?: string; + streamFrameRate?: number; + webrtcPortRange?: string; + recordingFps?: number; +}; const extractErrorMessage = (error: unknown, fallback: string): string => { if (error instanceof SandboxAgentError && error.problem?.detail) return error.problem.detail; if (error instanceof Error) return error.message; @@ -31,10 +56,10 @@ const formatDuration = (start: string, end?: string | null): string => { const secs = seconds % 60; return `${mins}m ${secs}s`; }; -const createScreenshotUrl = async (bytes: Uint8Array): Promise => { +const createScreenshotUrl = async (bytes: Uint8Array, mimeType = "image/png"): Promise => { const payload = new Uint8Array(bytes.byteLength); payload.set(bytes); - const blob = new Blob([payload.buffer], { type: "image/png" }); + const blob = new Blob([payload.buffer], { type: mimeType }); if (typeof URL.createObjectURL === "function") { return URL.createObjectURL(blob); } @@ -64,9 +89,15 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => { const [screenshotUrl, setScreenshotUrl] = useState(null); const [screenshotLoading, setScreenshotLoading] = useState(false); const [screenshotError, setScreenshotError] = useState(null); + const [screenshotFormat, setScreenshotFormat] = useState<"png" | "jpeg" | "webp">("png"); + const [screenshotQuality, setScreenshotQuality] = useState("85"); + const [screenshotScale, setScreenshotScale] = useState("1.0"); + const [showCursor, setShowCursor] = useState(false); // Live view const [liveViewActive, setLiveViewActive] = useState(false); const [liveViewError, setLiveViewError] = useState(null); + const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null); + const [mousePosLoading, setMousePosLoading] = useState(false); // Memoize the client as a DesktopViewerClient so the reference is stable // across renders and doesn't cause the DesktopViewer effect to re-fire. const viewerClient = useMemo(() => { @@ -85,8 +116,47 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => { const [recordingFps, setRecordingFps] = useState("30"); const [deletingRecordingId, setDeletingRecordingId] = useState(null); const [downloadingRecordingId, setDownloadingRecordingId] = useState(null); + const [showAdvancedStart, setShowAdvancedStart] = useState(false); + const [streamVideoCodec, setStreamVideoCodec] = useState("vp8"); + const [streamAudioCodec, setStreamAudioCodec] = useState("opus"); + const [streamFrameRate, setStreamFrameRate] = useState("30"); + const [webrtcPortRange, setWebrtcPortRange] = useState("59050-59070"); + const [defaultRecordingFps, setDefaultRecordingFps] = useState("30"); + const [clipboardText, setClipboardText] = useState(""); + const [clipboardSelection, setClipboardSelection] = useState<"clipboard" | "primary">("clipboard"); + const [clipboardLoading, setClipboardLoading] = useState(false); + const [clipboardError, setClipboardError] = useState(null); + const [clipboardWriteText, setClipboardWriteText] = useState(""); + const [clipboardWriting, setClipboardWriting] = useState(false); + const [windows, setWindows] = useState([]); + const [windowsLoading, setWindowsLoading] = useState(false); + const [windowsError, setWindowsError] = useState(null); + const [windowActing, setWindowActing] = useState(null); + const [editingWindow, setEditingWindow] = useState<{ id: string; action: "move" | "resize" } | null>(null); + const [editX, setEditX] = useState(""); + const [editY, setEditY] = useState(""); + const [editW, setEditW] = useState(""); + const [editH, setEditH] = useState(""); + const [launchApp, setLaunchApp] = useState("firefox"); + const [launchArgs, setLaunchArgs] = useState(""); + const [launchWait, setLaunchWait] = useState(true); + const [launching, setLaunching] = useState(false); + const [launchResult, setLaunchResult] = useState(null); + const [launchError, setLaunchError] = useState(null); + const [openTarget, setOpenTarget] = useState(""); + const [opening, setOpening] = useState(false); + const [openResult, setOpenResult] = useState(null); + const [openError, setOpenError] = useState(null); // Active recording tracking const activeRecording = useMemo(() => recordings.find((r) => r.status === "recording"), [recordings]); + const visibleWindows = useMemo(() => { + return windows.filter((win) => { + const title = win.title.trim(); + if (win.isActive) return true; + if (!title || title === "Openbox") return false; + return win.width >= 120 && win.height >= 80; + }); + }, [windows]); const revokeScreenshotUrl = useCallback(() => { setScreenshotUrl((current) => { if (current?.startsWith("blob:") && typeof URL.revokeObjectURL === "function") { @@ -103,6 +173,11 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => { try { const next = await getClient().getDesktopStatus(); setStatus(next); + // Status response now includes windows; sync them so we get window + // updates for free every time status is polled. + if (next.state === "active" && next.windows?.length) { + setWindows(next.windows); + } return next; } catch (loadError) { setError(extractErrorMessage(loadError, "Unable to load desktop status.")); @@ -118,16 +193,36 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => { setScreenshotLoading(true); setScreenshotError(null); try { - const bytes = await getClient().takeDesktopScreenshot(); + const quality = Number.parseInt(screenshotQuality, 10); + const scale = Number.parseFloat(screenshotScale); + const request: DesktopScreenshotRequest = { + format: screenshotFormat !== "png" ? screenshotFormat : undefined, + quality: screenshotFormat !== "png" && Number.isFinite(quality) ? quality : undefined, + scale: Number.isFinite(scale) && scale !== 1.0 ? scale : undefined, + showCursor: showCursor || undefined, + }; + const bytes = await getClient().takeDesktopScreenshot(request); revokeScreenshotUrl(); - setScreenshotUrl(await createScreenshotUrl(bytes)); + const mimeType = screenshotFormat === "jpeg" ? "image/jpeg" : screenshotFormat === "webp" ? "image/webp" : "image/png"; + setScreenshotUrl(await createScreenshotUrl(bytes, mimeType)); } catch (captureError) { revokeScreenshotUrl(); setScreenshotError(extractErrorMessage(captureError, "Unable to capture desktop screenshot.")); } finally { setScreenshotLoading(false); } - }, [getClient, revokeScreenshotUrl]); + }, [getClient, revokeScreenshotUrl, screenshotFormat, screenshotQuality, screenshotScale, showCursor]); + const loadMousePosition = useCallback(async () => { + setMousePosLoading(true); + try { + const pos = await getClient().getDesktopMousePosition(); + setMousePos({ x: pos.x, y: pos.y }); + } catch { + setMousePos(null); + } finally { + setMousePosLoading(false); + } + }, [getClient]); const loadRecordings = useCallback(async () => { setRecordingLoading(true); setRecordingError(null); @@ -140,15 +235,88 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => { setRecordingLoading(false); } }, [getClient]); + const loadClipboard = useCallback(async () => { + setClipboardLoading(true); + setClipboardError(null); + try { + const result = await getClient().getDesktopClipboard({ selection: clipboardSelection }); + setClipboardText(result.text); + } catch (err) { + setClipboardError(extractErrorMessage(err, "Unable to read clipboard.")); + } finally { + setClipboardLoading(false); + } + }, [clipboardSelection, getClient]); + const loadWindows = useCallback(async () => { + setWindowsLoading(true); + setWindowsError(null); + try { + const result = await getClient().listDesktopWindows(); + setWindows(result.windows); + } catch (err) { + setWindowsError(extractErrorMessage(err, "Unable to list windows.")); + } finally { + setWindowsLoading(false); + } + }, [getClient]); + const handleFocusWindow = async (windowId: string) => { + setWindowActing(windowId); + try { + await getClient().focusDesktopWindow(windowId); + await loadWindows(); + } catch (err) { + setWindowsError(extractErrorMessage(err, "Unable to focus window.")); + } finally { + setWindowActing(null); + } + }; + const handleMoveWindow = async (windowId: string) => { + const x = Number.parseInt(editX, 10); + const y = Number.parseInt(editY, 10); + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + setWindowActing(windowId); + try { + await getClient().moveDesktopWindow(windowId, { x, y }); + setEditingWindow(null); + await loadWindows(); + } catch (err) { + setWindowsError(extractErrorMessage(err, "Unable to move window.")); + } finally { + setWindowActing(null); + } + }; + const handleResizeWindow = async (windowId: string) => { + const nextWidth = Number.parseInt(editW, 10); + const nextHeight = Number.parseInt(editH, 10); + if (!Number.isFinite(nextWidth) || !Number.isFinite(nextHeight) || nextWidth <= 0 || nextHeight <= 0) return; + setWindowActing(windowId); + try { + await getClient().resizeDesktopWindow(windowId, { width: nextWidth, height: nextHeight }); + setEditingWindow(null); + await loadWindows(); + } catch (err) { + setWindowsError(extractErrorMessage(err, "Unable to resize window.")); + } finally { + setWindowActing(null); + } + }; useEffect(() => { void loadStatus(); }, [loadStatus]); + // Auto-refresh status (and windows via status) every 5 seconds when active + useEffect(() => { + if (status?.state !== "active") return; + const interval = setInterval(() => void loadStatus("refresh"), 5000); + return () => clearInterval(interval); + }, [status?.state, loadStatus]); useEffect(() => { if (status?.state === "active") { void loadRecordings(); } else { revokeScreenshotUrl(); setLiveViewActive(false); + setMousePos(null); + setEditingWindow(null); } }, [status?.state, loadRecordings, revokeScreenshotUrl]); useEffect(() => { @@ -160,19 +328,35 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => { const interval = setInterval(() => void loadRecordings(), 3000); return () => clearInterval(interval); }, [activeRecording, loadRecordings]); + useEffect(() => { + if (status?.state !== "active") { + setWindows([]); + return; + } + // Initial load; subsequent updates come from the status auto-refresh. + void loadWindows(); + }, [status?.state, loadWindows]); const handleStart = async () => { const parsedWidth = Number.parseInt(width, 10); const parsedHeight = Number.parseInt(height, 10); const parsedDpi = Number.parseInt(dpi, 10); + const parsedFrameRate = Number.parseInt(streamFrameRate, 10); + const parsedRecordingFps = Number.parseInt(defaultRecordingFps, 10); setActing("start"); setError(null); const startedAt = Date.now(); try { - const next = await getClient().startDesktop({ + const request: DesktopStartRequestWithAdvanced = { width: Number.isFinite(parsedWidth) ? parsedWidth : undefined, height: Number.isFinite(parsedHeight) ? parsedHeight : undefined, dpi: Number.isFinite(parsedDpi) ? parsedDpi : undefined, - }); + streamVideoCodec: streamVideoCodec !== "vp8" ? streamVideoCodec : undefined, + streamAudioCodec: streamAudioCodec !== "opus" ? streamAudioCodec : undefined, + streamFrameRate: Number.isFinite(parsedFrameRate) && parsedFrameRate !== 30 ? parsedFrameRate : undefined, + webrtcPortRange: webrtcPortRange !== "59050-59070" ? webrtcPortRange : undefined, + recordingFps: Number.isFinite(parsedRecordingFps) && parsedRecordingFps !== 30 ? parsedRecordingFps : undefined, + }; + const next = await getClient().startDesktop(request); setStatus(next); } catch (startError) { setError(extractErrorMessage(startError, "Unable to start desktop runtime.")); @@ -205,6 +389,18 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => { setActing(null); } }; + const handleWriteClipboard = async () => { + setClipboardWriting(true); + setClipboardError(null); + try { + await getClient().setDesktopClipboard({ text: clipboardWriteText, selection: clipboardSelection }); + setClipboardText(clipboardWriteText); + } catch (err) { + setClipboardError(extractErrorMessage(err, "Unable to write clipboard.")); + } finally { + setClipboardWriting(false); + } + }; const handleStartRecording = async () => { const fps = Number.parseInt(recordingFps, 10); setRecordingActing("start"); @@ -262,6 +458,41 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => { setDownloadingRecordingId(null); } }; + const handleLaunchApp = async () => { + if (!launchApp.trim()) return; + setLaunching(true); + setLaunchError(null); + setLaunchResult(null); + try { + const args = launchArgs.trim() ? launchArgs.trim().split(/\s+/) : undefined; + const result = await getClient().launchDesktopApp({ + app: launchApp.trim(), + args, + wait: launchWait || undefined, + }); + setLaunchResult(`Started ${result.processId}${result.windowId ? ` (window: ${result.windowId})` : ""}`); + await loadWindows(); + } catch (err) { + setLaunchError(extractErrorMessage(err, "Unable to launch app.")); + } finally { + setLaunching(false); + } + }; + const handleOpenTarget = async () => { + if (!openTarget.trim()) return; + setOpening(true); + setOpenError(null); + setOpenResult(null); + try { + const result = await getClient().openDesktopTarget({ target: openTarget.trim() }); + setOpenResult(`Opened via ${result.processId}`); + await loadWindows(); + } catch (err) { + setOpenError(extractErrorMessage(err, "Unable to open target.")); + } finally { + setOpening(false); + } + }; const canRefreshScreenshot = status?.state === "active"; const isActive = status?.state === "active"; const resolutionLabel = useMemo(() => { @@ -284,6 +515,48 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => { )} + {isActive && !liveViewActive && ( +
+
+ + +
+ {screenshotFormat !== "png" && ( +
+ + setScreenshotQuality(event.target.value)} + inputMode="numeric" + style={{ maxWidth: 60 }} + /> +
+ )} +
+ + setScreenshotScale(event.target.value)} + inputMode="decimal" + style={{ maxWidth: 60 }} + /> +
+ +
+ )} {error &&
{error}
} {screenshotError &&
{screenshotError}
} {/* ========== Runtime Section ========== */} @@ -329,6 +602,56 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => { setDpi(event.target.value)} inputMode="numeric" /> + + {showAdvancedStart && ( +
+
+ + +
+
+ + +
+
+ + setStreamFrameRate(event.target.value)} + inputMode="numeric" + disabled={isActive} + /> +
+
+ + setWebrtcPortRange(event.target.value)} disabled={isActive} /> +
+
+ + setDefaultRecordingFps(event.target.value)} + inputMode="numeric" + disabled={isActive} + /> +
+
+ )}
{isActive ? (
)} {!isActive &&
Start the desktop runtime to enable live view.
} - {isActive && liveViewActive && } + {isActive && liveViewActive && ( + <> +
+ Right click to open window + {status?.resolution && ( + + {status.resolution.width}x{status.resolution.height} + + )} +
+ + + )} {isActive && !liveViewActive && ( <> {screenshotUrl ? ( @@ -428,7 +773,313 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => { )} )} + {isActive && ( +
+ + {mousePos && ( + + ({mousePos.x}, {mousePos.y}) + + )} +
+ )} + {isActive && ( +
+
+ + + Clipboard + +
+ + +
+
+ {clipboardError && ( +
+ {clipboardError} +
+ )} +
+
Current contents
+
+              {clipboardText ? clipboardText : (empty)}
+            
+
+
+
Write to clipboard
+